Add Micrometer Observation instrumentation for GSP view rendering#15718
Add Micrometer Observation instrumentation for GSP view rendering#15718codeconsole wants to merge 12 commits into
Conversation
Wraps GroovyPageView rendering in a 'gsp.view' Observation, so each GSP page render becomes a timer (and, under a tracing bridge, a span nested in the request trace) — answering how much of a request is spent rendering the GSP. Follows the Micrometer/Spring instrumentation pattern: - GroovyPageObservationContext carries the view URI - GroovyPageObservationDocumentation documents the gsp.view observation and its low-cardinality keys (gsp.view, error) - GroovyPageObservationConvention + DefaultGroovyPageObservationConvention build the name / contextualName / KeyValues, and are overridable - GroovyPageView wraps renderTemplate via GSP_VIEW.observation(...) with a NOOP fast-path; GroovyPageViewResolver resolves the ObservationRegistry from the application context (NOOP fallback) and sets it on each view GroovyPageViewObservationSpec verifies the recorded observation name/tags, the NOOP no-op path, and the error key on a failed render. No new runtime dependency — micrometer-observation is already on the classpath (Spring 6); zero overhead when no ObservationRegistry bean is present.
…mplate) Generalizes the observation kit so the view, included templates, and layouts share one Context/Convention/Documentation: GroovyPageObservationContext now carries a generic resource name; the documentation enum gains GSP_TEMPLATE and GSP_LAYOUT and uses uniform low-cardinality keys (gsp.name, error); DefaultGroovyPageObservationConvention is parameterized by observation name. Instruments GroovyPagesTemplateRenderer.render with a 'gsp.template' observation (observeChecked, since render throws IOException), tagged with the template name. The ObservationRegistry is @Autowired (required=false), defaulting to NOOP with a fast-path. gsp.view keeps working (now tagged gsp.name); GSP_LAYOUT is declared for the follow-up.
Wraps the SiteMesh layout decoration (EmbeddedGrailsLayoutView -> decorator.render) in a 'gsp.layout' observation tagged with the layout page name, using the shared GSP observation kit. Only the decoration is wrapped (not obtainContent), so it does not double-count the inner gsp.view render — the two appear as sibling phases under the request trace. The ObservationRegistry is resolved in GrailsLayoutViewResolver from the refreshed application context and set on each GrailsLayoutView (NOOP fallback / fast-path).
…on (gsp.compile) Moves the observation kit (Context/Convention/Documentation) from grails-web-gsp down to grails-gsp-core so the template engine — which lives in core, below the web layer, and which core cannot import upward from web-gsp — can use it too. View/template/layout importers are repointed to the new org.grails.gsp.observation package. Instruments GroovyPagesTemplateEngine.buildPageMetaInfo with a 'gsp.compile' observation (observeChecked). Compilation only happens on a template cache miss, so the observation's count is the compile/miss rate and its timer is the compile latency; tagged with the GSP page name. The ObservationRegistry is resolved from the application context (NOOP fallback).
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15718 +/- ##
=================================================
- Coverage 48.3729% 0 -48.3729%
=================================================
Files 1870 0 -1870
Lines 85457 0 -85457
Branches 14900 0 -14900
=================================================
- Hits 41338 0 -41338
+ Misses 37784 0 -37784
+ Partials 6335 0 -6335 🚀 New features to boost your workflow:
|
Adds gsp.template.cache{result=hit|miss} counters to GroovyPagesTemplateEngine,
incremented per cacheable createTemplate() call based on whether the compiled-page
cache already held the entry — giving the cache hit ratio alongside the gsp.compile
(cache-miss) timer.
Counters are meter-only (unlike observations), so this resolves a MeterRegistry from
the application context and no-ops when absent. Adds micrometer-core as a BOM-managed
implementation dependency of grails-gsp-core; it is runtime-present in every Spring Boot
app (and runtime-transitive to consumers), so there is no class-load risk.
GroovyPagesTemplateEngineObservationSpec overrides the real GSP compilation with a stub and wires a recording ObservationRegistry + a SimpleMeterRegistry, then asserts: - compiling a page records a 'gsp.compile' observation tagged with the page name - two cacheable requests for the same page produce one miss + one hit (and exactly one compile), exercising the gsp.template.cache counters
There was a problem hiding this comment.
Pull request overview
This PR introduces Micrometer Observation-based instrumentation around Groovy Server Pages (GSP) rendering and compilation so GSP activity can be measured (timers) and traced (spans when a tracing bridge is present), and it adds cache hit/miss counters for the compiled-template cache.
Changes:
- Wraps GSP view rendering, template rendering, and SiteMesh layout decoration in
Observations (gsp.view,gsp.template,gsp.layout). - Instruments GSP compilation as
gsp.compileand recordsgsp.template.cachehit/miss counters. - Adds new observation support types (context, convention, documentation) and unit specs covering the behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/servlet/view/GroovyPageViewObservationSpec.groovy | Adds unit coverage for gsp.view observation behavior (success, NOOP, error tagging). |
| grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageViewResolver.java | Propagates an ObservationRegistry (and convention hook) into constructed GroovyPageView instances. |
| grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageView.java | Wraps view rendering in a gsp.view observation with a NOOP fast-path. |
| grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/GroovyPagesTemplateRenderer.java | Wraps template rendering in a gsp.template observation with a NOOP fast-path. |
| grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/GrailsLayoutViewResolver.java | Resolves and propagates an ObservationRegistry into layout views. |
| grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutView.java | Wraps layout decoration in a gsp.layout observation with a NOOP fast-path. |
| grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy | Adds unit coverage for gsp.compile observation and cache hit/miss counters. |
| grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java | Defines the documented observation set and common low-cardinality key names. |
| grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java | Defines the convention interface for GSP observation customization. |
| grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java | Adds a context type carrying the rendered resource name. |
| grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java | Implements the default naming + low-cardinality tags (gsp.name, error). |
| grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java | Adds gsp.compile observation and cache hit/miss counters to compilation/cache paths. |
| grails-gsp/core/build.gradle | Adds micrometer-core dependency to support counters/meters in core. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @Override | ||
| public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() { | ||
| return DefaultGroovyPageObservationConvention.class; | ||
| } |
| private static final GroovyPageObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.layout"); | ||
| private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; | ||
| private GroovyPageObservationConvention observationConvention; |
| * Sets a custom {@link GroovyPageObservationConvention} applied to GSP view observations. | ||
| */ | ||
| public void setObservationConvention(GroovyPageObservationConvention observationConvention) { | ||
| this.observationConvention = observationConvention; | ||
| } |
| if (cacheable) { | ||
| recordCacheAccess(pageCache.containsKey(pageName)); | ||
| meta = CacheEntry.getValue(pageCache, pageName, -1, null, | ||
| new GroovyPagesTemplateEngineCallable(new GroovyPagesTemplateEngineCacheEntry(pageName)), | ||
| true, resource); |
| // GSP rendering/compilation Micrometer instrumentation (gsp.view/template/layout/compile + cache counters) | ||
| implementation 'io.micrometer:micrometer-core' |
…miss - DefaultGroovyPageObservationConvention gets a no-arg constructor (name 'gsp') so it honors ObservationDocumentation.getDefaultConvention() reflectively; instrumentation sites still pass an explicitly-named instance. - GSP template cache hit/miss is now counted from whether a compile actually ran (a ThreadLocal flag set in buildPageMetaInfo) instead of pageCache.containsKey(), which mis-counted expired/reloaded entries as hits and could race on cold start.
…stable - GrailsLayoutViewResolver now propagates a custom GroovyPageObservationConvention to the GrailsLayoutView it builds (previously the field on the view had no setter, so a configured convention was silently dropped for gsp.layout observations). - EmbeddedGrailsLayoutView gains setObservationConvention to receive it. - GroovyPagesTemplateRenderer.doRender is now protected so it can be overridden in tests, isolating the gsp.template observation wrapper from the render pipeline. - Add GroovyPagesTemplateRendererObservationSpec covering the success, NOOP, and error paths (mirrors GroovyPageViewObservationSpec).
…ut test Review follow-ups: - GroovyPagesTemplateEngine: replace the thread-local 'compiledOnThread' flag (used to tell a cache hit from a recompile) with a per-call PageCompileRequest carried through CacheEntry's cacheRequestObject. The thread-local mis-counted when template creation re-entered on the same thread (an inner cache hit reset the flag, so the outer lookup that actually compiled was recorded as a hit), and it also lingered on pooled request threads (never removed). The per-call holder is reentrancy-safe and leak-free; updateValue (the only cacheable compile path) sets compiled=true. Add a regression test for the reentrant-hit case. - EmbeddedGrailsLayoutView.renderWithLayout is now protected so the gsp.layout observation can be unit-tested in isolation. Add EmbeddedGrailsLayoutViewObservationSpec (success / NOOP / error paths), mirroring the view and template renderer specs. Add byte-buddy to the layout module's test runtime so Spock can mock concrete collaborators (consistent with grails-web-gsp/grails-sitemesh3).
…; gsp.name high-cardinality
The gsp.template.cache hit/miss counter lived on GroovyPagesTemplateEngine's runtime-compile cache, which is development-only: a precompiled production deployment serves GSPs from AOT-compiled classes and bypasses that cache, so it can never register a hit there (and the higher layers intercept any second lookup). Move hit/miss to the caches actually consulted on the request path in a deployed app -- GroovyPagesTemplateRenderer.templateCache (cache=template) and GroovyPageViewResolver.viewCache (cache=view) -- as the gsp.cache{cache,result} counter (new GroovyPageCacheMetrics helper).
Make gsp.name high-cardinality (span attribute) instead of low-cardinality (metric tag): on an app with many GSPs it exploded the render-timer series count. error remains the only low-cardinality tag.
gsp.view/gsp.template/gsp.layout render timers are unchanged. gsp.compile is kept and documented as a dev/cold-start signal -- its presence on a precompiled production deployment flags a view that is not precompiled. Adds api dependency io.micrometer:micrometer-core to grails-web-gsp (MeterRegistry now appears in its setMeterRegistry ABI).
✅ All tests passed ✅🏷️ Commit: 36a8101 Learn more about TestLens at testlens.app. |
What
Adds Micrometer Observation instrumentation for GSP view rendering. Each GSP page render runs inside a
gsp.viewObservation, so it produces a timer metric and — under a tracing bridge — a span nested in the request trace, answering "how much of a request is spent rendering the GSP".Why
GSP rendering (and SiteMesh layout) is often a large, currently-invisible slice of request latency. Spring instruments the HTTP request (
http.server.requests) but nothing surfaces the view-render portion. This adds it at the framework level.How
Follows the Micrometer/Spring instrumentation pattern (mirrors
ServerHttpObservation*):GroovyPageObservationContext— carries the view URIGroovyPageObservationDocumentation— documents thegsp.viewobservation + low-cardinality keys (gsp.view,error)GroovyPageObservationConvention+DefaultGroovyPageObservationConvention— name / contextualName / KeyValues, overridable via a custom beanGroovyPageView.renderTemplatewraps rendering viaGSP_VIEW.observation(...)with anObservationRegistry.isNoop()fast-pathGroovyPageViewResolverresolves theObservationRegistryfrom the application context (NOOP fallback) and sets it on each viewNotes
micrometer-observationis already on the classpath (Spring 6). Zero overhead when noObservationRegistrybean is present (NOOP fast-path).GroovyPageViewObservationSpec(recorded name/tags, NOOP no-op path, error key on a failed render).Follow-ups (same pattern, not in this PR)
gsp.template(GroovyPagesTemplateRenderer),gsp.layout(GrailsLayoutView), andgsp.compile+ template-cache hit/miss (GroovyPagesTemplateEngine).