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