Skip to content

8.x Add Micrometer Observation instrumentation for GSP view rendering#15718

Open
codeconsole wants to merge 19 commits into
apache:8.0.xfrom
codeconsole:feat/gsp-rendering-observability
Open

8.x Add Micrometer Observation instrumentation for GSP view rendering#15718
codeconsole wants to merge 19 commits into
apache:8.0.xfrom
codeconsole:feat/gsp-rendering-observability

Conversation

@codeconsole

@codeconsole codeconsole commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Depends on #15750 (shared grails-observation foundation). Merge #15750 first; this branch merges it in to build the GSP convention on the shared base. After #15750 lands on 8.0.x, rebasing drops those commits, leaving only the GSP diff.

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.cache counter (tagged cache=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 name
  • GroovyPageObservationDocumentation — documents the gsp.* observations, with error as the only low-cardinality (metric) key and gsp.name as a high-cardinality (span-only) key
  • GroovyPageObservationConvention + DefaultGroovyPageObservationConvention — name / contextualName / KeyValues; customizable either by registering a convention on the ObservationRegistry or via the per-component setter
  • Each instrumentation site wraps rendering via GSP_*.observation(custom, default, ctx, registry) with an ObservationRegistry.isNoop() fast-path; the explicit default convention carries the per-stage name
  • Resolvers (GroovyPageViewResolver, GrailsLayoutViewResolver) resolve the ObservationRegistry (and MeterRegistry for cache counters) from the application context, falling back to NOOP, and set them on each view
  • GroovyPageCacheMetrics records hit/miss using an actual "had to build the entry" signal (the CacheEntry updater flag / entry == null), not a containsKey heuristic, so reloadable/expired entries are counted as misses

Dependency

Adds io.micrometer:micrometer-coreimplementation in grails-gsp/core and api in grails-web-gsp (the MeterRegistry/Counter types in the gsp.cache counters and the setMeterRegistry(...) ABI come from micrometer-core, not from micrometer-observation). The observation timers/spans themselves need only micrometer-observation, which is already transitively present via Spring. Overhead is zero when no ObservationRegistry/MeterRegistry bean 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, or none) is the only metric tag.

Tests

  • GroovyPageViewObservationSpecgsp.view recorded name/tags, NOOP no-op path, error key on a failed render
  • GroovyPagesTemplateRendererObservationSpecgsp.template
  • EmbeddedGrailsLayoutViewObservationSpecgsp.layout
  • GroovyPagesTemplateEngineObservationSpecgsp.compile and gsp.cache hit/miss

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.
@codeconsole codeconsole marked this pull request as draft June 5, 2026 08:18
…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

codecov Bot commented Jun 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.0000%. Comparing base (098660a) to head (36a8101).
⚠️ Report is 2 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@                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     

see 1870 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Copilot AI left a comment

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.

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.compile and records gsp.template.cache hit/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.

Comment thread grails-gsp/core/build.gradle
…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).
@codeconsole codeconsole marked this pull request as ready for review June 7, 2026 15:35
@codeconsole codeconsole changed the title Add Micrometer Observation instrumentation for GSP view rendering 8.x Add Micrometer Observation instrumentation for GSP view rendering Jun 12, 2026
…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.
@testlens-app

testlens-app Bot commented Jun 22, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

CI - Groovy Joint Validation Build / build_grails > :grails-test-examples-scaffolding:integrationTest

Test Runs
UserControllerSpec > User list ❌ ✅

🏷️ Commit: 12cb31d
▶️ Tests: 49171 executed
⚪️ Checks: 46/46 completed


Learn more about TestLens at testlens.app.

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