diff --git a/grails-gsp/core/build.gradle b/grails-gsp/core/build.gradle
index 8197b52dc16..3642c757711 100644
--- a/grails-gsp/core/build.gradle
+++ b/grails-gsp/core/build.gradle
@@ -44,6 +44,8 @@ dependencies {
api project(':grails-core')
api project(':grails-taglib')
api 'org.apache.groovy:groovy-templates'
+ // GSP rendering/compilation Micrometer instrumentation (gsp.view/template/layout/compile + cache counters)
+ implementation 'io.micrometer:micrometer-core'
// api project(':grails-bootstrap'), { // ConfigMap
// // API dependencies in grails-bootstrap
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java
index f82e642ff1e..bd0b6292d78 100644
--- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPagesTemplateEngine.java
@@ -40,6 +40,8 @@
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.runtime.IOGroovyMethods;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -76,6 +78,10 @@
import org.grails.gsp.io.GroovyPageResourceScriptSource;
import org.grails.gsp.io.GroovyPageScriptSource;
import org.grails.gsp.jsp.TagLibraryResolver;
+import org.grails.gsp.observation.DefaultGroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationContext;
+import org.grails.gsp.observation.GroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationDocumentation;
import org.grails.taglib.TagLibraryLookup;
/**
@@ -108,6 +114,9 @@ public class GroovyPagesTemplateEngine extends ResourceAwareTemplateEngine imple
private ClassLoader classLoader;
private AtomicInteger scriptNameCount = new AtomicInteger(0);
+ private static final GroovyPageObservationConvention COMPILE_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.compile");
+ private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
+
private GroovyPageLocator groovyPageLocator = new DefaultGroovyPageLocator();
private boolean reloadEnabled;
@@ -481,6 +490,18 @@ public Template createTemplate(InputStream inputStream) {
}
protected GroovyPageMetaInfo buildPageMetaInfo(Resource resource, String pageName) throws IOException {
+ if (this.observationRegistry.isNoop()) {
+ return doBuildPageMetaInfo(resource, pageName);
+ }
+ // Compilation only happens on a template cache miss, so the count of this observation is
+ // effectively the GSP compile (cache-miss) rate; its timer is the compile latency.
+ Observation observation = GroovyPageObservationDocumentation.GSP_COMPILE.observation(
+ null, COMPILE_OBSERVATION_CONVENTION,
+ () -> new GroovyPageObservationContext(pageName), this.observationRegistry);
+ return observation.observeChecked(() -> doBuildPageMetaInfo(resource, pageName));
+ }
+
+ private GroovyPageMetaInfo doBuildPageMetaInfo(Resource resource, String pageName) throws IOException {
InputStream inputStream = resource.getInputStream();
try {
return buildPageMetaInfo(inputStream, resource, pageName);
@@ -754,6 +775,8 @@ public void setApplicationContext(ApplicationContext applicationContext) throws
Config config = grailsApplication.getConfig();
this.gspEncoding = config.getProperty(GroovyPageParser.CONFIG_PROPERTY_GSP_ENCODING, System.getProperty("file.encoding", GroovyPageParser.DEFAULT_ENCODING));
}
+ this.observationRegistry = applicationContext.getBeanProvider(ObservationRegistry.class)
+ .getIfAvailable(() -> ObservationRegistry.NOOP);
}
/**
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java
new file mode 100644
index 00000000000..810ccc205d7
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java
@@ -0,0 +1,100 @@
+/*
+ * 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 org.grails.gsp.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+
+import static org.grails.gsp.observation.GroovyPageObservationDocumentation.HighCardinalityKeyNames;
+import static org.grails.gsp.observation.GroovyPageObservationDocumentation.LowCardinalityKeyNames;
+
+/**
+ * Default {@link GroovyPageObservationConvention}.
+ *
+ *
Names the observation as configured ({@code gsp.view} / {@code gsp.template} / {@code gsp.layout}),
+ * attaches {@code error} (exception simple name, or {@code "none"}) as the only low-cardinality
+ * key value (the only one that becomes a metric tag), and {@code gsp.name} (the rendered resource) as a
+ * high-cardinality key value, so it appears on the span/trace but does not explode the metric's
+ * tag set on applications with many GSPs.
+ *
+ * @author Grails
+ * @since 8.0
+ */
+public class DefaultGroovyPageObservationConvention implements GroovyPageObservationConvention {
+
+ private static final KeyValue ERROR_NONE = LowCardinalityKeyNames.ERROR.withValue("none");
+
+ private static final String UNKNOWN = "unknown";
+
+ private static final String DEFAULT_NAME = "gsp";
+
+ private final String name;
+
+ /**
+ * Creates a convention with the generic {@code "gsp"} name. Exists so this class can satisfy
+ * {@link io.micrometer.observation.docs.ObservationDocumentation#getDefaultConvention()} via
+ * reflection; instrumentation sites always pass an explicitly-named instance.
+ */
+ public DefaultGroovyPageObservationConvention() {
+ this(DEFAULT_NAME);
+ }
+
+ /**
+ * @param name the observation name (e.g. {@code gsp.view}, {@code gsp.template}, {@code gsp.layout})
+ */
+ public DefaultGroovyPageObservationConvention(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String getName() {
+ return this.name;
+ }
+
+ @Override
+ public String getContextualName(GroovyPageObservationContext context) {
+ return this.name + " " + resource(context);
+ }
+
+ @Override
+ public KeyValues getLowCardinalityKeyValues(GroovyPageObservationContext context) {
+ return KeyValues.of(error(context));
+ }
+
+ @Override
+ public KeyValues getHighCardinalityKeyValues(GroovyPageObservationContext context) {
+ return KeyValues.of(name(context));
+ }
+
+ protected KeyValue name(GroovyPageObservationContext context) {
+ return HighCardinalityKeyNames.NAME.withValue(resource(context));
+ }
+
+ protected KeyValue error(GroovyPageObservationContext context) {
+ Throwable error = context.getError();
+ return (error != null) ?
+ LowCardinalityKeyNames.ERROR.withValue(error.getClass().getSimpleName()) :
+ ERROR_NONE;
+ }
+
+ private static String resource(GroovyPageObservationContext context) {
+ String resource = context.getResource();
+ return (resource != null && !resource.isEmpty()) ? resource : UNKNOWN;
+ }
+}
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageCacheMetrics.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageCacheMetrics.java
new file mode 100644
index 00000000000..cb38d705c31
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageCacheMetrics.java
@@ -0,0 +1,88 @@
+/*
+ * 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 org.grails.gsp.observation;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+
+/**
+ * Records hit/miss for a GSP cache as the {@code gsp.cache} counter, tagged {@code cache}
+ * (which cache: {@code template} or {@code view}) and {@code result} ({@code hit} / {@code miss}).
+ *
+ * This deliberately instruments the caches that are actually consulted on the request path of a
+ * deployed (non-development) application — the {@code } template cache
+ * ({@code GroovyPagesTemplateRenderer}) and the view-resolver cache ({@code GroovyPageViewResolver})
+ * — rather than the {@code GroovyPagesTemplateEngine}'s runtime compile cache. A
+ * production deployment serves GSPs precompiled, which bypasses the engine's compile cache entirely
+ * (and the layers above intercept any second lookup), so the engine cache can never register a hit
+ * in production and is therefore not a useful operational signal there.
+ *
+ * Note these caches do not expire by default ({@code cacheTimeout == -1}), so a steady-state
+ * ratio sits at ~100%; the actionable signal is the miss rate, which
+ * spikes on cold start / deploy and on any unexpected cache flush.
+ *
+ * @author Grails
+ * @since 8.0
+ */
+public final class GroovyPageCacheMetrics {
+
+ /** A no-op instance used when no {@link MeterRegistry} is available. */
+ public static final GroovyPageCacheMetrics NOOP = new GroovyPageCacheMetrics(null, null);
+
+ private static final String METRIC_NAME = "gsp.cache";
+
+ private final Counter hits;
+ private final Counter misses;
+
+ private GroovyPageCacheMetrics(Counter hits, Counter misses) {
+ this.hits = hits;
+ this.misses = misses;
+ }
+
+ /**
+ * Builds the hit/miss counters for the named cache, or returns {@link #NOOP} when metrics are
+ * not configured.
+ *
+ * @param meterRegistry the registry to register the counters with, or {@code null} to disable
+ * @param cache the value for the {@code cache} tag, e.g. {@code "template"} or {@code "view"}
+ * @return a recorder bound to {@code meterRegistry}, or {@link #NOOP}
+ */
+ public static GroovyPageCacheMetrics forCache(MeterRegistry meterRegistry, String cache) {
+ if (meterRegistry == null) {
+ return NOOP;
+ }
+ Counter hits = Counter.builder(METRIC_NAME).tag("cache", cache).tag("result", "hit")
+ .description("GSP " + cache + " cache lookups served from cache").register(meterRegistry);
+ Counter misses = Counter.builder(METRIC_NAME).tag("cache", cache).tag("result", "miss")
+ .description("GSP " + cache + " cache lookups that had to build the entry").register(meterRegistry);
+ return new GroovyPageCacheMetrics(hits, misses);
+ }
+
+ /**
+ * Records a single cache access.
+ *
+ * @param hit {@code true} if the value was served from cache, {@code false} if it had to be built
+ */
+ public void record(boolean hit) {
+ Counter counter = hit ? this.hits : this.misses;
+ if (counter != null) {
+ counter.increment();
+ }
+ }
+}
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java
new file mode 100644
index 00000000000..47597ca440f
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java
@@ -0,0 +1,47 @@
+/*
+ * 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 org.grails.gsp.observation;
+
+import io.micrometer.observation.Observation;
+
+/**
+ * Context that holds information for {@link io.micrometer.observation.Observation} instrumentation
+ * of Groovy Server Pages (GSP) rendering — the view, an included template, or a layout.
+ *
+ * @author Grails
+ * @since 8.0
+ * @see GroovyPageObservationDocumentation
+ */
+public class GroovyPageObservationContext extends Observation.Context {
+
+ private final String resource;
+
+ public GroovyPageObservationContext(String resource) {
+ this.resource = resource;
+ }
+
+ /**
+ * Return the name of the rendered resource — the view URI (e.g. {@code /book/show}), the
+ * template name, or the layout name, depending on the observation.
+ * @return the resource name, possibly {@code null}
+ */
+ public String getResource() {
+ return this.resource;
+ }
+}
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java
new file mode 100644
index 00000000000..e7967b070d8
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java
@@ -0,0 +1,41 @@
+/*
+ * 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 org.grails.gsp.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationConvention;
+
+/**
+ * {@link ObservationConvention} for GSP view rendering instrumentation.
+ *
+ * Implement this interface and register it as a bean (or set it on the
+ * {@code GroovyPageViewResolver}) to customize the observation name and the
+ * {@link io.micrometer.common.KeyValues} attached to GSP view observations.
+ *
+ * @author Grails
+ * @since 8.0
+ * @see DefaultGroovyPageObservationConvention
+ */
+public interface GroovyPageObservationConvention extends ObservationConvention {
+
+ @Override
+ default boolean supportsContext(Observation.Context context) {
+ return context instanceof GroovyPageObservationContext;
+ }
+}
diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java
new file mode 100644
index 00000000000..e9c07452041
--- /dev/null
+++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java
@@ -0,0 +1,110 @@
+/*
+ * 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 org.grails.gsp.observation;
+
+import io.micrometer.common.docs.KeyName;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationConvention;
+import io.micrometer.observation.docs.ObservationDocumentation;
+
+/**
+ * Documented {@link io.micrometer.observation.Observation}s for Groovy Server Pages (GSP) rendering.
+ *
+ * The observation name ({@code gsp.view} / {@code gsp.template} / {@code gsp.layout}) distinguishes
+ * what was rendered. {@code error} is the only {@link LowCardinalityKeyNames low-cardinality} key, so it
+ * is the only one that becomes a metric tag. {@code gsp.name} (the rendered resource path) is a
+ * {@link HighCardinalityKeyNames high-cardinality} key — it is attached to the span/trace for drilldown
+ * but deliberately kept off the timer, because a real application has hundreds or thousands of distinct
+ * GSPs and tagging the metric per-resource would explode the time-series cardinality.
+ *
+ * Applicability across run modes: {@code gsp.view} / {@code gsp.template} / {@code gsp.layout} fire on
+ * every render and are meaningful in production. {@code gsp.compile} fires only when a GSP is compiled at
+ * runtime — expected in development, but on a precompiled (production) deployment it should essentially
+ * never appear, so its presence there is a useful signal that a view is not precompiled.
+ *
+ * @author Grails
+ * @since 8.0
+ */
+public enum GroovyPageObservationDocumentation implements ObservationDocumentation {
+
+ /**
+ * Rendering of a single GSP view.
+ */
+ GSP_VIEW,
+
+ /**
+ * Rendering of an included GSP template (e.g. {@code }).
+ */
+ GSP_TEMPLATE,
+
+ /**
+ * Decoration of rendered content by a GSP layout (SiteMesh).
+ */
+ GSP_LAYOUT,
+
+ /**
+ * Runtime compilation of a GSP into its {@code GroovyPageMetaInfo}. Happens on a runtime
+ * template-compile cache miss — i.e. in development, or when a view is not precompiled. A
+ * precompiled production deployment should not emit this.
+ */
+ GSP_COMPILE;
+
+ @Override
+ public Class extends ObservationConvention extends Observation.Context>> getDefaultConvention() {
+ return DefaultGroovyPageObservationConvention.class;
+ }
+
+ @Override
+ public KeyName[] getLowCardinalityKeyNames() {
+ return LowCardinalityKeyNames.values();
+ }
+
+ @Override
+ public KeyName[] getHighCardinalityKeyNames() {
+ return HighCardinalityKeyNames.values();
+ }
+
+ public enum LowCardinalityKeyNames implements KeyName {
+
+ /**
+ * Simple name of the exception thrown during rendering, or {@code "none"}.
+ */
+ ERROR {
+ @Override
+ public String asString() {
+ return "error";
+ }
+ }
+ }
+
+ public enum HighCardinalityKeyNames implements KeyName {
+
+ /**
+ * Name of the rendered resource (view URI, template name, or layout name). High-cardinality:
+ * attached to the span for drilldown, but not to the metric, to avoid per-resource time-series
+ * explosion on applications with many GSPs.
+ */
+ NAME {
+ @Override
+ public String asString() {
+ return "gsp.name";
+ }
+ }
+ }
+}
diff --git a/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy
new file mode 100644
index 00000000000..5fd6db1b129
--- /dev/null
+++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy
@@ -0,0 +1,85 @@
+/*
+ * 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 org.grails.gsp
+
+import io.micrometer.observation.Observation
+import io.micrometer.observation.ObservationHandler
+import io.micrometer.observation.ObservationRegistry
+
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.io.ByteArrayResource
+import org.springframework.core.io.Resource
+
+import spock.lang.Specification
+
+/**
+ * Tests the {@code gsp.compile} observation on {@link GroovyPagesTemplateEngine}. The real GSP
+ * compilation is overridden so the test stays a unit.
+ *
+ * Note: GSP cache hit/miss is no longer instrumented here. The engine's runtime-compile cache is
+ * a development-only signal (a precompiled production deployment bypasses it), so the operational
+ * {@code gsp.cache} hit/miss counters live on the request-path caches in {@code GroovyPagesTemplateRenderer}
+ * and {@code GroovyPageViewResolver} instead.
+ */
+class GroovyPagesTemplateEngineObservationSpec extends Specification {
+
+ private List recorded = []
+
+ /** Engine whose actual compilation returns a stub, wired with a recording observation registry. */
+ private GroovyPagesTemplateEngine engine() {
+ ObservationRegistry observationRegistry = ObservationRegistry.create()
+ observationRegistry.observationConfig().observationHandler(new ObservationHandler() {
+ @Override boolean supportsContext(Observation.Context context) { true }
+ @Override void onStop(Observation.Context context) { recorded << context }
+ })
+
+ GroovyPagesTemplateEngine engine = new GroovyPagesTemplateEngine() {
+ @Override
+ protected GroovyPageMetaInfo buildPageMetaInfo(InputStream inputStream, Resource res, String pageName) {
+ return new GroovyPageMetaInfo()
+ }
+ }
+ engine.setReloadEnabled(false)
+
+ GenericApplicationContext ctx = new GenericApplicationContext()
+ ctx.getBeanFactory().registerSingleton('observationRegistry', observationRegistry)
+ ctx.refresh()
+ engine.setApplicationContext(ctx)
+ return engine
+ }
+
+ private Resource gsp() {
+ new ByteArrayResource('hi'.bytes)
+ }
+
+ void "compiling a page records a gsp.compile observation tagged with the page name"() {
+ when:
+ engine().buildPageMetaInfo(gsp(), '/book/show')
+
+ then:
+ recorded.size() == 1
+ recorded[0].name == 'gsp.compile'
+ recorded[0].contextualName == 'gsp.compile /book/show'
+
+ and: "gsp.name is high-cardinality (span only); error is the low-cardinality metric tag"
+ recorded[0].highCardinalityKeyValues.find { it.key == 'gsp.name' }?.value == '/book/show'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'gsp.name' } == null
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'none'
+ }
+}
diff --git a/grails-gsp/grails-layout/build.gradle b/grails-gsp/grails-layout/build.gradle
index 5994b859264..4268a35ee91 100644
--- a/grails-gsp/grails-layout/build.gradle
+++ b/grails-gsp/grails-layout/build.gradle
@@ -60,6 +60,7 @@ dependencies {
// impl: MockHttpServletRequest
}
testImplementation project(':grails-testing-support-web')
+ testRuntimeOnly 'net.bytebuddy:byte-buddy' // lets Spock mock concrete types (e.g. GrailsWebRequest) in tests
testRuntimeOnly 'org.slf4j:slf4j-nop' // Get rid of warning about missing slf4j implementation during test task
}
diff --git a/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutView.java b/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutView.java
index 86487a29d0b..cce78594819 100644
--- a/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutView.java
+++ b/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutView.java
@@ -28,12 +28,18 @@
import com.opensymphony.module.sitemesh.RequestConstants;
import com.opensymphony.sitemesh.Content;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.View;
+import org.grails.gsp.observation.DefaultGroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationContext;
+import org.grails.gsp.observation.GroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationDocumentation;
import org.grails.web.servlet.WrappedResponseHolder;
import org.grails.web.servlet.mvc.GrailsWebRequest;
import org.grails.web.servlet.mvc.OutputAwareHttpServletResponse;
@@ -48,6 +54,10 @@ public class EmbeddedGrailsLayoutView extends AbstractGrailsView {
public static final String GSP_GRAILS_LAYOUT_PAGE = EmbeddedGrailsLayoutView.class.getName() + ".GSP_GRAILS_LAYOUT_PAGE";
+ private static final GroovyPageObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.layout");
+ private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
+ private GroovyPageObservationConvention observationConvention;
+
public EmbeddedGrailsLayoutView(GroovyPageLayoutFinder groovyPageLayoutFinder, View innerView) {
this.groovyPageLayoutFinder = groovyPageLayoutFinder;
this.innerView = innerView;
@@ -85,7 +95,7 @@ protected void renderTemplate(Map model, GrailsWebRequest webReq
LOG.debug("Found layout. Rendering content for layout {} and model {}", decorator.getPage(), model);
}
- decorator.render(content, model, request, response, webRequest.getServletContext());
+ renderWithLayout(decorator, content, model, request, response, webRequest);
return;
}
break;
@@ -103,6 +113,34 @@ protected void renderTemplate(Map model, GrailsWebRequest webReq
}
+ protected void renderWithLayout(SpringMVCViewDecorator decorator, Content content, Map model,
+ HttpServletRequest request, HttpServletResponse response, GrailsWebRequest webRequest) throws Exception {
+ if (this.observationRegistry.isNoop()) {
+ decorator.render(content, model, request, response, webRequest.getServletContext());
+ return;
+ }
+ Observation observation = GroovyPageObservationDocumentation.GSP_LAYOUT.observation(
+ this.observationConvention, DEFAULT_OBSERVATION_CONVENTION,
+ () -> new GroovyPageObservationContext(decorator.getPage()), this.observationRegistry);
+ observation.observeChecked(() -> decorator.render(content, model, request, response, webRequest.getServletContext()));
+ }
+
+ /**
+ * Sets the {@link ObservationRegistry} used to instrument GSP layout (SiteMesh) decoration. Defaults
+ * to {@link ObservationRegistry#NOOP}, in which case layout decoration is not observed.
+ */
+ public void setObservationRegistry(ObservationRegistry observationRegistry) {
+ this.observationRegistry = (observationRegistry != null) ? observationRegistry : ObservationRegistry.NOOP;
+ }
+
+ /**
+ * Sets a custom {@link GroovyPageObservationConvention} for layout-decoration observations. When
+ * {@code null} the default convention is used.
+ */
+ public void setObservationConvention(GroovyPageObservationConvention observationConvention) {
+ this.observationConvention = observationConvention;
+ }
+
protected void beforeDecorating(Content content, Map model, GrailsWebRequest webRequest,
HttpServletRequest request, HttpServletResponse response) {
applyMetaHttpEquivContentType(content, response);
diff --git a/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/GrailsLayoutViewResolver.java b/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/GrailsLayoutViewResolver.java
index 7036d8174ca..74fe567d768 100644
--- a/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/GrailsLayoutViewResolver.java
+++ b/grails-gsp/grails-layout/src/main/groovy/org/apache/grails/web/layout/GrailsLayoutViewResolver.java
@@ -30,6 +30,7 @@
import com.opensymphony.module.sitemesh.factory.DefaultFactory;
import com.opensymphony.sitemesh.ContentProcessor;
import com.opensymphony.sitemesh.compatability.PageParser2ContentProcessor;
+import io.micrometer.observation.ObservationRegistry;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.context.ApplicationListener;
@@ -47,18 +48,30 @@ public class GrailsLayoutViewResolver extends EmbeddedGrailsLayoutViewResolver i
protected GrailsApplication grailsApplication;
private boolean grailsLayoutConfigLoaded = false;
private int order = Ordered.LOWEST_PRECEDENCE - 50;
+ private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
+ private org.grails.gsp.observation.GroovyPageObservationConvention observationConvention;
public GrailsLayoutViewResolver() {
super();
}
+ /**
+ * Sets a custom convention applied to {@code gsp.layout} observations on the views this resolver builds.
+ */
+ public void setObservationConvention(org.grails.gsp.observation.GroovyPageObservationConvention observationConvention) {
+ this.observationConvention = observationConvention;
+ }
+
public GrailsLayoutViewResolver(ViewResolver innerViewResolver, GroovyPageLayoutFinder groovyPageLayoutFinder) {
super(innerViewResolver, groovyPageLayoutFinder);
}
@Override
protected View createLayoutView(View innerView) {
- return new GrailsLayoutView(groovyPageLayoutFinder, innerView, contentProcessor);
+ GrailsLayoutView layoutView = new GrailsLayoutView(groovyPageLayoutFinder, innerView, contentProcessor);
+ layoutView.setObservationRegistry(this.observationRegistry);
+ layoutView.setObservationConvention(this.observationConvention);
+ return layoutView;
}
public void init() {
@@ -140,6 +153,8 @@ public void setOrder(int order) {
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
+ this.observationRegistry = event.getApplicationContext()
+ .getBeanProvider(ObservationRegistry.class).getIfAvailable(() -> ObservationRegistry.NOOP);
init();
}
}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutViewObservationSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutViewObservationSpec.groovy
new file mode 100644
index 00000000000..beb8d77214d
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutViewObservationSpec.groovy
@@ -0,0 +1,133 @@
+/*
+ * 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 org.apache.grails.web.layout
+
+import com.opensymphony.sitemesh.Content
+
+import io.micrometer.common.KeyValues
+import io.micrometer.observation.Observation
+import io.micrometer.observation.ObservationHandler
+import io.micrometer.observation.ObservationRegistry
+
+import jakarta.servlet.ServletContext
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+
+import org.grails.web.servlet.mvc.GrailsWebRequest
+import org.springframework.web.servlet.View
+
+import spock.lang.Specification
+
+/**
+ * Tests that {@link EmbeddedGrailsLayoutView} records a {@code gsp.layout} observation around SiteMesh
+ * decoration when an {@link ObservationRegistry} is configured, following the Micrometer Observation
+ * pattern. A stub {@link SpringMVCViewDecorator} isolates the observation wrapper from the decoration
+ * pipeline (the layout module has no byte-buddy on the test classpath, so the concrete decorator can't
+ * be mocked directly).
+ */
+class EmbeddedGrailsLayoutViewObservationSpec extends Specification {
+
+ private List recorded = []
+
+ /** A decorator whose render() is controllable and whose page name is fixed for tag assertions. */
+ private static class StubDecorator extends SpringMVCViewDecorator {
+ boolean rendered = false
+ RuntimeException toThrow
+
+ StubDecorator(View view) {
+ super('main', view)
+ }
+
+ @Override
+ String getPage() { '/layouts/main' }
+
+ @Override
+ void render(Content content, Map model, HttpServletRequest request,
+ HttpServletResponse response, ServletContext servletContext) {
+ rendered = true
+ if (toThrow != null) {
+ throw toThrow
+ }
+ }
+ }
+
+ private ObservationRegistry recordingRegistry() {
+ ObservationRegistry registry = ObservationRegistry.create()
+ registry.observationConfig().observationHandler(new ObservationHandler() {
+ @Override boolean supportsContext(Observation.Context context) { true }
+ @Override void onStop(Observation.Context context) { recorded << context }
+ })
+ registry
+ }
+
+ private EmbeddedGrailsLayoutView viewFor(ObservationRegistry registry) {
+ EmbeddedGrailsLayoutView view = new EmbeddedGrailsLayoutView(null, null)
+ view.observationRegistry = registry
+ view
+ }
+
+ void "a gsp.layout observation is recorded with the layout page name on a successful decoration"() {
+ given:
+ EmbeddedGrailsLayoutView view = viewFor(recordingRegistry())
+ StubDecorator decorator = new StubDecorator(Mock(View))
+
+ when:
+ view.renderWithLayout(decorator, Mock(Content), [:], null, null, Mock(GrailsWebRequest))
+
+ then:
+ decorator.rendered
+ recorded.size() == 1
+ recorded[0].name == 'gsp.layout'
+ recorded[0].contextualName == 'gsp.layout /layouts/main'
+
+ and: "gsp.name is high-cardinality (span only); error is the low-cardinality metric tag"
+ recorded[0].highCardinalityKeyValues.find { it.key == 'gsp.name' }?.value == '/layouts/main'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'gsp.name' } == null
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'none'
+ }
+
+ void "no observation is recorded when the registry is NOOP (zero overhead)"() {
+ given:
+ EmbeddedGrailsLayoutView view = viewFor(ObservationRegistry.NOOP)
+ StubDecorator decorator = new StubDecorator(Mock(View))
+
+ when:
+ view.renderWithLayout(decorator, Mock(Content), [:], null, null, Mock(GrailsWebRequest))
+
+ then:
+ decorator.rendered
+ recorded.isEmpty()
+ }
+
+ void "the error key carries the exception name when decoration fails"() {
+ given:
+ EmbeddedGrailsLayoutView view = viewFor(recordingRegistry())
+ StubDecorator decorator = new StubDecorator(Mock(View))
+ decorator.toThrow = new IllegalStateException('boom')
+
+ when:
+ view.renderWithLayout(decorator, Mock(Content), [:], null, null, Mock(GrailsWebRequest))
+
+ then:
+ thrown(IllegalStateException)
+ recorded.size() == 1
+ recorded[0].name == 'gsp.layout'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'IllegalStateException'
+ }
+}
diff --git a/grails-gsp/grails-web-gsp/build.gradle b/grails-gsp/grails-web-gsp/build.gradle
index d08ff77fdad..3622af16e88 100644
--- a/grails-gsp/grails-web-gsp/build.gradle
+++ b/grails-gsp/grails-web-gsp/build.gradle
@@ -45,6 +45,7 @@ dependencies {
api project(':grails-gsp-core')
api project(':grails-web-common')
api project(':grails-web-taglib')
+ api 'io.micrometer:micrometer-core' // MeterRegistry appears in setMeterRegistry(...) ABI; used for gsp.cache hit/miss (GroovyPageCacheMetrics)
// api 'org.apache.grails:grails-core', { // GrailsDomainClass, CacheEntry, Environment, GrailsStringUtils, GrailsApplication, GrailsControllerClass, GrailsApplicationAware, ControllerArtefactHandler, GrailsPluginManager, GrailsFactoriesLoader
// // API dependencies in grails-core
diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/GroovyPagesTemplateRenderer.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/GroovyPagesTemplateRenderer.java
index 7c704b82ba9..f903a409769 100644
--- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/GroovyPagesTemplateRenderer.java
+++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/GroovyPagesTemplateRenderer.java
@@ -33,7 +33,12 @@
import groovy.text.Template;
import org.codehaus.groovy.runtime.InvokerHelper;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+
import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
@@ -54,6 +59,11 @@
import org.grails.gsp.GroovyPageMetaInfo;
import org.grails.gsp.GroovyPagesTemplateEngine;
import org.grails.gsp.io.GroovyPageScriptSource;
+import org.grails.gsp.observation.DefaultGroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageCacheMetrics;
+import org.grails.gsp.observation.GroovyPageObservationContext;
+import org.grails.gsp.observation.GroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationDocumentation;
import org.grails.io.support.GrailsResourceUtils;
import org.grails.taglib.GrailsTagException;
import org.grails.taglib.TemplateVariableBinding;
@@ -84,6 +94,10 @@ public class GroovyPagesTemplateRenderer implements InitializingBean {
private Method generateViewMethod;
private boolean reloadEnabled;
private boolean cacheEnabled = !Environment.isDevelopmentMode();
+ private static final GroovyPageObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.template");
+ private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
+ private GroovyPageObservationConvention observationConvention;
+ private GroovyPageCacheMetrics cacheMetrics = GroovyPageCacheMetrics.NOOP;
public void afterPropertiesSet() throws Exception {
if (scaffoldingTemplateGenerator != null) {
@@ -103,6 +117,32 @@ public void setReloadEnabled(boolean reloadEnabled) {
this.reloadEnabled = reloadEnabled;
}
+ /**
+ * Sets the {@link ObservationRegistry} used to instrument GSP template rendering. Defaults to
+ * {@link ObservationRegistry#NOOP}, in which case template rendering is not observed.
+ */
+ @Autowired(required = false)
+ public void setObservationRegistry(ObservationRegistry observationRegistry) {
+ this.observationRegistry = (observationRegistry != null) ? observationRegistry : ObservationRegistry.NOOP;
+ }
+
+ /**
+ * Sets a custom {@link GroovyPageObservationConvention}. When {@code null} the default convention is used.
+ */
+ public void setObservationConvention(GroovyPageObservationConvention observationConvention) {
+ this.observationConvention = observationConvention;
+ }
+
+ /**
+ * Sets the {@link MeterRegistry} used to record {@code } template cache hits/misses as the
+ * {@code gsp.cache} counter ({@code cache=template}). This is one of the caches actually consulted on
+ * the request path in a deployed app. When unset, cache metrics are disabled.
+ */
+ @Autowired(required = false)
+ public void setMeterRegistry(MeterRegistry meterRegistry) {
+ this.cacheMetrics = GroovyPageCacheMetrics.forCache(meterRegistry, "template");
+ }
+
public void clearCache() {
templateCache.clear();
}
@@ -115,6 +155,18 @@ public void render(GrailsWebRequest webRequest, TemplateVariableBinding pageScop
throw new GrailsTagException("Tag [render] is missing required attribute [template]");
}
+ if (this.observationRegistry.isNoop()) {
+ doRender(templateName, webRequest, pageScope, attrs, body, out);
+ return;
+ }
+ Observation observation = GroovyPageObservationDocumentation.GSP_TEMPLATE.observation(
+ this.observationConvention, DEFAULT_OBSERVATION_CONVENTION,
+ () -> new GroovyPageObservationContext(templateName), this.observationRegistry);
+ observation.observeChecked(() -> doRender(templateName, webRequest, pageScope, attrs, body, out));
+ }
+
+ protected void doRender(String templateName, GrailsWebRequest webRequest, TemplateVariableBinding pageScope,
+ Map attrs, Object body, Writer out) throws IOException {
String uri = webRequest.getAttributes().getTemplateUri(templateName, webRequest.getRequest());
String contextPath = getStringValue(attrs, "contextPath");
String pluginName = getStringValue(attrs, "plugin");
@@ -158,7 +210,10 @@ private Template findAndCacheTemplate(Object controller, TemplateVariableBinding
}
}
- return CacheEntry.getValue(templateCache, cacheKey, reloadEnabled ? GroovyPageMetaInfo.LASTMODIFIED_CHECK_INTERVAL : -1, null,
+ // Carries a one-shot "built" flag through CacheEntry (which ignores the request object by
+ // default); updateValue sets it when it actually builds, so a lookup that did not build is a hit.
+ final boolean[] built = { false };
+ Template template = CacheEntry.getValue(templateCache, cacheKey, reloadEnabled ? GroovyPageMetaInfo.LASTMODIFIED_CHECK_INTERVAL : -1, null,
new Callable>() {
public CacheEntry call() {
return new CacheEntry<>() {
@@ -185,6 +240,10 @@ public void setValue(Template val) {
@Override
protected Template updateValue(Template oldValue, Callable updater, Object cacheRequestObject)
throws Exception {
+ // updateValue is invoked by CacheEntry only on a cold/expired entry — a miss.
+ if (cacheRequestObject instanceof boolean[]) {
+ ((boolean[]) cacheRequestObject)[0] = true;
+ }
Template t = null;
if (scriptSource != null) {
t = groovyPagesTemplateEngine.createTemplate(scriptSource);
@@ -201,7 +260,9 @@ protected Template updateValue(Template oldValue, Callable updater, Ob
}
};
}
- }, true, null);
+ }, true, built);
+ cacheMetrics.record(!built[0]);
+ return template;
}
@SuppressWarnings({"rawtypes", "unchecked"})
diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageView.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageView.java
index 15c23656e3b..b94de8efe02 100644
--- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageView.java
+++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageView.java
@@ -26,6 +26,8 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -39,6 +41,10 @@
import org.grails.gsp.GroovyPageWritable;
import org.grails.gsp.GroovyPagesException;
import org.grails.gsp.GroovyPagesTemplateEngine;
+import org.grails.gsp.observation.DefaultGroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationContext;
+import org.grails.gsp.observation.GroovyPageObservationConvention;
+import org.grails.gsp.observation.GroovyPageObservationDocumentation;
import org.grails.web.pages.GSPResponseWriter;
import org.grails.web.servlet.mvc.GrailsWebRequest;
@@ -68,9 +74,25 @@ public class GroovyPageView extends AbstractGrailsView {
public static final String EXCEPTION_MODEL_KEY = "exception";
private static boolean developmentMode = Environment.isDevelopmentMode();
+ private static final GroovyPageObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.view");
+ private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
+ private GroovyPageObservationConvention observationConvention;
+
@Override
protected void renderTemplate(Map model, GrailsWebRequest webRequest, HttpServletRequest request,
HttpServletResponse response) {
+ if (this.observationRegistry.isNoop()) {
+ doRenderTemplate(model, webRequest, request, response);
+ return;
+ }
+ Observation observation = GroovyPageObservationDocumentation.GSP_VIEW.observation(
+ this.observationConvention, DEFAULT_OBSERVATION_CONVENTION,
+ () -> new GroovyPageObservationContext(getUrl()), this.observationRegistry);
+ observation.observe(() -> doRenderTemplate(model, webRequest, request, response));
+ }
+
+ protected void doRenderTemplate(Map model, GrailsWebRequest webRequest, HttpServletRequest request,
+ HttpServletResponse response) {
request.setAttribute(GroovyPagesUriService.RENDERING_VIEW_ATTRIBUTE, Boolean.TRUE);
GSPResponseWriter out = null;
try {
@@ -156,6 +178,24 @@ public void setTemplateEngine(GroovyPagesTemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
+ /**
+ * Sets the {@link ObservationRegistry} used to instrument GSP view rendering. Defaults to
+ * {@link ObservationRegistry#NOOP}, in which case rendering is not observed.
+ * @param observationRegistry the registry, or {@code null} for no-op
+ */
+ public void setObservationRegistry(ObservationRegistry observationRegistry) {
+ this.observationRegistry = (observationRegistry != null) ? observationRegistry : ObservationRegistry.NOOP;
+ }
+
+ /**
+ * Sets a custom {@link GroovyPageObservationConvention}. When {@code null} the
+ * {@link DefaultGroovyPageObservationConvention} is used.
+ * @param observationConvention the convention, or {@code null} for the default
+ */
+ public void setObservationConvention(GroovyPageObservationConvention observationConvention) {
+ this.observationConvention = observationConvention;
+ }
+
public boolean isExpired() {
return System.currentTimeMillis() - createTimestamp > LASTMODIFIED_CHECK_INTERVAL;
}
diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageViewResolver.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageViewResolver.java
index 1d6e838b485..4bb28223d2a 100644
--- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageViewResolver.java
+++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/servlet/view/GroovyPageViewResolver.java
@@ -27,11 +27,14 @@
import jakarta.servlet.http.HttpServletRequest;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.observation.ObservationRegistry;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.ApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.scripting.ScriptSource;
import org.springframework.util.Assert;
@@ -44,6 +47,8 @@
import grails.util.GrailsUtil;
import org.grails.gsp.GroovyPagesTemplateEngine;
import org.grails.gsp.io.GroovyPageScriptSource;
+import org.grails.gsp.observation.GroovyPageCacheMetrics;
+import org.grails.gsp.observation.GroovyPageObservationConvention;
import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator;
import org.grails.web.servlet.mvc.GrailsWebRequest;
@@ -66,6 +71,9 @@ public class GroovyPageViewResolver extends InternalResourceViewResolver impleme
private boolean allowGrailsViewCaching = !GrailsUtil.isDevelopmentEnv();
private long cacheTimeout = -1;
private boolean resolveJspView = false;
+ private volatile ObservationRegistry observationRegistry;
+ private GroovyPageObservationConvention observationConvention;
+ private GroovyPageCacheMetrics cacheMetrics = GroovyPageCacheMetrics.NOOP;
/**
* Constructor.
@@ -94,6 +102,8 @@ protected View loadView(String viewName, Locale locale) throws Exception {
}
if (!allowGrailsViewCaching) {
+ // view caching disabled (development) → the View is rebuilt every time
+ cacheMetrics.record(false);
return createGrailsView(viewName);
}
@@ -119,6 +129,8 @@ public View call() throws Exception {
View view = null;
if (entry == null) {
+ // no cache entry yet → this lookup builds the View (a miss)
+ cacheMetrics.record(false);
try {
return CacheEntry.getValue(viewCache, viewCacheKey, cacheTimeout, updater);
}
@@ -129,6 +141,8 @@ public View call() throws Exception {
}
}
+ // existing entry served from cache (never expires by default) → a hit
+ cacheMetrics.record(true);
try {
view = entry.getValue(cacheTimeout, updater, true, null);
} catch (WrappedInitializationException e) {
@@ -221,6 +235,8 @@ private View createGroovyPageView(String gspView, ScriptSource scriptSource) {
gspSpringView.setApplicationContext(getApplicationContext());
gspSpringView.setTemplateEngine(templateEngine);
gspSpringView.setScriptSource(scriptSource);
+ gspSpringView.setObservationRegistry(resolveObservationRegistry());
+ gspSpringView.setObservationConvention(this.observationConvention);
try {
gspSpringView.afterPropertiesSet();
if (LOG.isDebugEnabled()) {
@@ -232,6 +248,51 @@ private View createGroovyPageView(String gspView, ScriptSource scriptSource) {
return gspSpringView;
}
+ /**
+ * Resolves the {@link ObservationRegistry} to apply to GSP views: an explicitly configured one
+ * if set, otherwise the registry bean from the application context, falling back to
+ * {@link ObservationRegistry#NOOP} when none is available.
+ */
+ private ObservationRegistry resolveObservationRegistry() {
+ ObservationRegistry registry = this.observationRegistry;
+ if (registry == null) {
+ ApplicationContext ctx = getApplicationContext();
+ registry = (ctx != null) ?
+ ctx.getBeanProvider(ObservationRegistry.class).getIfAvailable(() -> ObservationRegistry.NOOP) :
+ ObservationRegistry.NOOP;
+ // Benign race: two threads may both resolve and write the volatile field before it is set.
+ // Resolution is idempotent (same context bean, or NOOP), so the duplicate is harmless and
+ // avoiding it isn't worth synchronizing this hot path.
+ this.observationRegistry = registry;
+ }
+ return registry;
+ }
+
+ /**
+ * Sets the {@link ObservationRegistry} used to instrument GSP view rendering. When left unset it
+ * is resolved from the application context (falling back to {@link ObservationRegistry#NOOP}).
+ */
+ public void setObservationRegistry(ObservationRegistry observationRegistry) {
+ this.observationRegistry = observationRegistry;
+ }
+
+ /**
+ * Sets a custom {@link GroovyPageObservationConvention} applied to GSP view observations.
+ */
+ public void setObservationConvention(GroovyPageObservationConvention observationConvention) {
+ this.observationConvention = observationConvention;
+ }
+
+ /**
+ * Sets the {@link MeterRegistry} used to record view-resolver cache hits/misses as the
+ * {@code gsp.cache} counter ({@code cache=view}). This is one of the caches actually consulted on
+ * the request path in a deployed app. When unset, cache metrics are disabled.
+ */
+ @Autowired(required = false)
+ public void setMeterRegistry(MeterRegistry meterRegistry) {
+ this.cacheMetrics = GroovyPageCacheMetrics.forCache(meterRegistry, "view");
+ }
+
protected View createFallbackView(String viewName) throws Exception {
if (resolveJspView) {
if (LOG.isDebugEnabled()) {
diff --git a/grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/gsp/GroovyPagesTemplateRendererObservationSpec.groovy b/grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/gsp/GroovyPagesTemplateRendererObservationSpec.groovy
new file mode 100644
index 00000000000..934c2097d6a
--- /dev/null
+++ b/grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/gsp/GroovyPagesTemplateRendererObservationSpec.groovy
@@ -0,0 +1,109 @@
+/*
+ * 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 org.grails.web.gsp
+
+import io.micrometer.common.KeyValues
+import io.micrometer.observation.Observation
+import io.micrometer.observation.ObservationHandler
+import io.micrometer.observation.ObservationRegistry
+
+import org.grails.gsp.GroovyPagesException
+import org.grails.gsp.GroovyPagesTemplateEngine
+import org.grails.web.servlet.mvc.GrailsWebRequest
+
+import spock.lang.Specification
+
+/**
+ * Tests that {@link GroovyPagesTemplateRenderer} records a {@code gsp.template} observation when an
+ * {@link ObservationRegistry} is configured, following the Micrometer Observation pattern.
+ *
+ * The actual {@code } pipeline is overridden ({@code doRender}) so the test
+ * isolates the observation wrapper from the rendering machinery.
+ */
+class GroovyPagesTemplateRendererObservationSpec extends Specification {
+
+ private List recorded = []
+
+ private ObservationRegistry recordingRegistry() {
+ ObservationRegistry registry = ObservationRegistry.create()
+ registry.observationConfig().observationHandler(new ObservationHandler() {
+ @Override boolean supportsContext(Observation.Context context) { true }
+ @Override void onStop(Observation.Context context) { recorded << context }
+ })
+ registry
+ }
+
+ /** A renderer whose actual render body is supplied by a closure, bypassing the GSP pipeline. */
+ private GroovyPagesTemplateRenderer rendererFor(ObservationRegistry registry, Closure body = {}) {
+ GroovyPagesTemplateRenderer renderer = new GroovyPagesTemplateRenderer() {
+ @Override
+ protected void doRender(String templateName, GrailsWebRequest webRequest,
+ org.grails.taglib.TemplateVariableBinding pageScope, Map attrs,
+ Object body2, Writer out) {
+ body.call()
+ }
+ }
+ renderer.groovyPagesTemplateEngine = Mock(GroovyPagesTemplateEngine)
+ renderer.observationRegistry = registry
+ renderer
+ }
+
+ void "a gsp.template observation is recorded with the template name on a successful render"() {
+ given:
+ GroovyPagesTemplateRenderer renderer = rendererFor(recordingRegistry())
+
+ when:
+ renderer.render(null, null, [template: '/shared/_card'], null, new StringWriter())
+
+ then:
+ recorded.size() == 1
+ recorded[0].name == 'gsp.template'
+ recorded[0].contextualName == 'gsp.template /shared/_card'
+
+ and: "gsp.name is high-cardinality (span only); error is the low-cardinality metric tag"
+ recorded[0].highCardinalityKeyValues.find { it.key == 'gsp.name' }?.value == '/shared/_card'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'gsp.name' } == null
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'none'
+ }
+
+ void "no observation is recorded when the registry is NOOP (zero overhead)"() {
+ given:
+ GroovyPagesTemplateRenderer renderer = rendererFor(ObservationRegistry.NOOP)
+
+ when:
+ renderer.render(null, null, [template: '/shared/_card'], null, new StringWriter())
+
+ then:
+ recorded.isEmpty()
+ }
+
+ void "the error key carries the exception name when rendering fails"() {
+ given:
+ GroovyPagesTemplateRenderer renderer = rendererFor(recordingRegistry(), { throw new GroovyPagesException('boom') })
+
+ when:
+ renderer.render(null, null, [template: '/shared/_card'], null, new StringWriter())
+
+ then:
+ thrown(GroovyPagesException)
+ recorded.size() == 1
+ recorded[0].name == 'gsp.template'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'GroovyPagesException'
+ }
+}
diff --git a/grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/servlet/view/GroovyPageViewObservationSpec.groovy b/grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/servlet/view/GroovyPageViewObservationSpec.groovy
new file mode 100644
index 00000000000..8741faacf61
--- /dev/null
+++ b/grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/servlet/view/GroovyPageViewObservationSpec.groovy
@@ -0,0 +1,110 @@
+/*
+ * 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 org.grails.web.servlet.view
+
+import io.micrometer.common.KeyValues
+import io.micrometer.observation.Observation
+import io.micrometer.observation.ObservationHandler
+import io.micrometer.observation.ObservationRegistry
+
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+
+import org.grails.gsp.GroovyPagesException
+import org.grails.web.servlet.mvc.GrailsWebRequest
+
+import spock.lang.Specification
+
+/**
+ * Tests that {@link GroovyPageView} records a {@code gsp.view} observation when an
+ * {@link ObservationRegistry} is configured, following the Micrometer Observation pattern.
+ *
+ * The actual GSP rendering is overridden ({@code doRenderTemplate}) so the test isolates the
+ * observation wrapper from the rendering pipeline.
+ */
+class GroovyPageViewObservationSpec extends Specification {
+
+ private List recorded = []
+
+ private ObservationRegistry recordingRegistry() {
+ ObservationRegistry registry = ObservationRegistry.create()
+ registry.observationConfig().observationHandler(new ObservationHandler() {
+ @Override boolean supportsContext(Observation.Context context) { true }
+ @Override void onStop(Observation.Context context) { recorded << context }
+ })
+ registry
+ }
+
+ /** A view whose actual render body is supplied by a closure, bypassing the GSP pipeline. */
+ private GroovyPageView viewFor(ObservationRegistry registry, String url = '/book/show', Closure body = {}) {
+ GroovyPageView view = new GroovyPageView() {
+ @Override
+ protected void doRenderTemplate(Map model, GrailsWebRequest webRequest,
+ HttpServletRequest request, HttpServletResponse response) {
+ body.call()
+ }
+ }
+ view.url = url
+ view.observationRegistry = registry
+ view
+ }
+
+ void "a gsp.view observation is recorded with the view URI on a successful render"() {
+ given:
+ GroovyPageView view = viewFor(recordingRegistry())
+
+ when:
+ view.renderTemplate([:], null, null, null)
+
+ then:
+ recorded.size() == 1
+ recorded[0].name == 'gsp.view'
+ recorded[0].contextualName == 'gsp.view /book/show'
+
+ and: "gsp.name is high-cardinality (span only); error is the low-cardinality metric tag"
+ recorded[0].highCardinalityKeyValues.find { it.key == 'gsp.name' }?.value == '/book/show'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'gsp.name' } == null
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'none'
+ }
+
+ void "no observation is recorded when the registry is NOOP (zero overhead)"() {
+ given:
+ GroovyPageView view = viewFor(ObservationRegistry.NOOP)
+
+ when:
+ view.renderTemplate([:], null, null, null)
+
+ then:
+ recorded.isEmpty()
+ }
+
+ void "the error key carries the exception name when rendering fails"() {
+ given:
+ GroovyPageView view = viewFor(recordingRegistry(), '/book/show', { throw new GroovyPagesException('boom') })
+
+ when:
+ view.renderTemplate([:], null, null, null)
+
+ then:
+ thrown(GroovyPagesException)
+ recorded.size() == 1
+ recorded[0].name == 'gsp.view'
+ recorded[0].lowCardinalityKeyValues.find { it.key == 'error' }?.value == 'GroovyPagesException'
+ }
+}