8.x Add Micrometer Observation instrumentation for GSP view rendering#15718
8.x Add Micrometer Observation instrumentation for GSP view rendering#15718codeconsole wants to merge 19 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.
…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).
…oller and grails.render spans
Grails dispatches requests through its own URL mappings rather than Spring MVC
handler methods, so Spring never sets the observation path pattern and the
http.server.requests `uri` tag falls back to UNKNOWN — collapsing every endpoint
into one metric series. The request lifecycle between the root HTTP server span
and the leaf DB/GSP spans (controller action, view render) is also untraced.
Adds three things, all best-effort and fully guarded (never affect request
handling) and NOOP-fast-pathed when observations are disabled:
- uri route: UrlMappingsHandlerMapping adds an ObservationRouteHandler
(HandlerInterceptor). In preHandle — where the observation context is attached
and the controller/action are resolved — it sets
ServerRequestObservationContext.setPathPattern("/<controller>/<action>"), a
low-cardinality route. The `uri` tag becomes e.g. /book/show, restoring
per-endpoint metrics. The action falls back to the controller's configured
defaultAction (not an assumed 'index'). (Setting it earlier, during URL-mapping
match, is too soon — the observation context is not on the request yet.)
- grails.controller span: UrlMappingsInfoHandlerAdapter wraps the controller
action invocation (controllerClass.invoke) in the GrailsObservationDocumentation
CONTROLLER observation (grails.controller / grails.action tags). It is a child of
the HTTP server span and the parent of any DB/cache spans the action triggers
(the scope is open across invoke).
- grails.render span: GrailsDispatcherServlet overrides render() to wrap the
view-render phase (view resolution + sitemesh decoration + response write) in the
GrailsObservationDocumentation RENDER observation (grails.view tag). It becomes
the parent of the existing gsp.view spans, so "render excluding GSP" falls out as
grails.render minus its GSP children, and non-view (e.g. JSON) renders are
captured on their own.
The two spans use the micrometer ObservationConvention/ObservationDocumentation
pattern — Context + Convention + default convention + a shared
GrailsObservationDocumentation enum — in a new org.grails.web.observation package
in grails-web-common, mirroring the GSP rendering observations so the observations
are documented and customizable. A convention spec covers the emitted tags,
fallbacks, error capture, contextual names and documentation wiring.
A trace is then a clean breakdown: http(route) -> security -> {controller, db,
render -> gsp}. The ObservationRegistry is resolved from the ApplicationContext;
grails-web-common gains an io.micrometer:micrometer-observation api dependency.
…erceptor, databinding and convert spans
Builds on the controller/render observations: extracts the duplicated convention
boilerplate into a new leaf module and instruments three more Grails request-lifecycle
phases on top of it, so a trace reads http(route) -> interceptor -> controller ->
databinding -> {render -> gsp | convert(json/xml)}.
New module `grails-observation` (org.apache.grails.observation): a micrometer-only leaf
(no Grails deps, so GORM and GSP can reuse it later) holding the shared base —
GrailsObservationContext, GrailsObservationConvention<C> (supportsContext by context type,
the low-cardinality `error` key value, the null/empty fallback) and the shared `error`
KeyName. Registered in settings.gradle and the publish-root-config.gradle publishedProjects
list (required, or grails-publish never applies and configuration fails).
The controller/render conventions (grails-web-common) are refactored onto the base, dropping
their duplicated error/fallback logic and sharing the `error` KeyName.
New spans — each a Context + Convention + default convention + ObservationDocumentation built
on the shared base, NOOP-fast-pathed and fully guarded (never affect request handling):
- grails.interceptor (grails-interceptors): one span per matched interceptor before()/after()
callback, tagged grails.interceptor=<logical name> and grails.interceptor.phase. The adapter
is made ObservationRegistry-aware; the boolean short-circuit/control flow is preserved.
- grails.databinding (grails-web-databinding): wraps GrailsWebDataBinder.doBind, tagged
grails.databinding.target; the registry is resolved from the GrailsApplication main context.
- grails.convert (grails-converters): wraps JSON/XML.render(HttpServletResponse), which write
straight to the response and bypass DispatcherServlet.render, tagged grails.convert.format.
Converters are not Spring beans, so the registry rides the existing ConvertersConfigurationHolder
(set once by ConvertersConfigurationInitializer).
Convention specs cover the new conventions' tags, fallbacks, error capture and documentation
wiring; the shared base is exercised through the existing controller/render spec.
GORM (gorm.query/persist) and validate observations are deferred to follow-ups that reuse this
foundation.
…ion foundation DefaultGroovyPageObservationConvention now extends GrailsObservationConvention (from the new grails-observation module), dropping its duplicated error keyvalue, null/empty fallback and supportsContext logic and reusing the shared GrailsObservationKeyNames.ERROR. GroovyPageObservationDocumentation references the shared error key; the convention interface drops its now-inherited supportsContext default. The gsp.name high-cardinality key and the observation names are unchanged. Behaviour is unchanged: the gsp.view/template/layout/compile observation specs all pass, including the error-key and NOOP (zero-overhead) cases. Depends on the grails-observation foundation from apache#15750 (merged into this branch). Once apache#15750 lands on 8.0.x, rebasing this branch drops those commits, leaving only the GSP diff.
…rrect the convention javadoc Addresses review: DefaultGroovyPageObservationConvention (public) extends GrailsObservationConvention from grails-observation, so the dependency belongs on the api configuration (matching grails-web-common) rather than implementation, to avoid a runtime-only scope in the published POM for external consumers. Also corrects the GroovyPageObservationConvention javadoc — custom conventions are set via setObservationConvention(...), not registered as beans.
✅ All tests passed ✅Test SummaryCI - Groovy Joint Validation Build / build_grails > :grails-test-examples-scaffolding:integrationTest
🏷️ Commit: 12cb31d Learn more about TestLens at testlens.app. |
What
Adds Micrometer Observation instrumentation across the GSP rendering pipeline. Each stage runs inside its own Observation, 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 GSP, and where":
gsp.view— top-level GSP view render (GroovyPageView)gsp.template— included<g:render template="..."/>(GroovyPagesTemplateRenderer)gsp.layout— SiteMesh layout decoration (EmbeddedGrailsLayoutView)gsp.compile— runtime GSP compilation (GroovyPagesTemplateEngine), which only fires on a real compile (dev / non-precompiled views)It also adds a
gsp.cachecounter (taggedcache=template|view,result=hit|miss) for the caches actually consulted on a deployed request path (GroovyPagesTemplateRenderer,GroovyPageViewResolver).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, with enough granularity to attribute time to view vs. template vs. layout, and to distinguish compile/cache-miss cost from steady-state rendering.How
Follows the Micrometer/Spring instrumentation pattern (mirrors
ServerHttpObservation*):GroovyPageObservationContext— carries the rendered resource nameGroovyPageObservationDocumentation— documents thegsp.*observations, witherroras the only low-cardinality (metric) key andgsp.nameas a high-cardinality (span-only) keyGroovyPageObservationConvention+DefaultGroovyPageObservationConvention— name / contextualName / KeyValues; customizable either by registering a convention on theObservationRegistryor via the per-component setterGSP_*.observation(custom, default, ctx, registry)with anObservationRegistry.isNoop()fast-path; the explicit default convention carries the per-stage nameGroovyPageViewResolver,GrailsLayoutViewResolver) resolve theObservationRegistry(andMeterRegistryfor cache counters) from the application context, falling back to NOOP, and set them on each viewGroovyPageCacheMetricsrecords hit/miss using an actual "had to build the entry" signal (theCacheEntryupdater flag /entry == null), not acontainsKeyheuristic, so reloadable/expired entries are counted as missesDependency
Adds
io.micrometer:micrometer-core—implementationingrails-gsp/coreandapiingrails-web-gsp(theMeterRegistry/Countertypes in thegsp.cachecounters and thesetMeterRegistry(...)ABI come frommicrometer-core, not frommicrometer-observation). The observation timers/spans themselves need onlymicrometer-observation, which is already transitively present via Spring. Overhead is zero when noObservationRegistry/MeterRegistrybean is present (NOOP fast-path).Cardinality
gsp.name(the rendered resource path) is high-cardinality: attached to the span for drilldown but kept off the timer, because a real app has many distinct GSPs and tagging the metric per-resource would explode the time-series count.error(exception simple name, ornone) is the only metric tag.Tests
GroovyPageViewObservationSpec—gsp.viewrecorded name/tags, NOOP no-op path, error key on a failed renderGroovyPagesTemplateRendererObservationSpec—gsp.templateEmbeddedGrailsLayoutViewObservationSpec—gsp.layoutGroovyPagesTemplateEngineObservationSpec—gsp.compileandgsp.cachehit/miss