From f1c5ea343c1f6b8cd6a0410a206cc4de21e1539f Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 01:01:13 -0700 Subject: [PATCH 01/12] Add Micrometer Observation instrumentation for GSP view rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps GroovyPageView rendering in a 'gsp.view' Observation, so each GSP page render becomes a timer (and, under a tracing bridge, a span nested in the request trace) — answering how much of a request is spent rendering the GSP. Follows the Micrometer/Spring instrumentation pattern: - GroovyPageObservationContext carries the view URI - GroovyPageObservationDocumentation documents the gsp.view observation and its low-cardinality keys (gsp.view, error) - GroovyPageObservationConvention + DefaultGroovyPageObservationConvention build the name / contextualName / KeyValues, and are overridable - GroovyPageView wraps renderTemplate via GSP_VIEW.observation(...) with a NOOP fast-path; GroovyPageViewResolver resolves the ObservationRegistry from the application context (NOOP fallback) and sets it on each view GroovyPageViewObservationSpec verifies the recorded observation name/tags, the NOOP no-op path, and the error key on a failed render. No new runtime dependency — micrometer-observation is already on the classpath (Spring 6); zero overhead when no ObservationRegistry bean is present. --- ...efaultGroovyPageObservationConvention.java | 83 +++++++++++++ .../GroovyPageObservationContext.java | 46 ++++++++ .../GroovyPageObservationConvention.java | 41 +++++++ .../GroovyPageObservationDocumentation.java | 71 +++++++++++ .../web/servlet/view/GroovyPageView.java | 41 +++++++ .../servlet/view/GroovyPageViewResolver.java | 40 +++++++ .../view/GroovyPageViewObservationSpec.groovy | 110 ++++++++++++++++++ 7 files changed, 432 insertions(+) create mode 100644 grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java create mode 100644 grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java create mode 100644 grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationConvention.java create mode 100644 grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java create mode 100644 grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/servlet/view/GroovyPageViewObservationSpec.groovy diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java new file mode 100644 index 00000000000..0025886eb9b --- /dev/null +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java @@ -0,0 +1,83 @@ +/* + * 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.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; + +import static org.grails.web.gsp.observation.GroovyPageObservationDocumentation.LowCardinalityKeyNames; + +/** + * Default {@link GroovyPageObservationConvention}. + * + *

Names the observation {@code gsp.view} and attaches the {@code gsp.view} (view URI) + * and {@code error} (exception simple name, or {@code "none"}) low-cardinality key values.

+ * + * @author Grails + * @since 8.0 + */ +public class DefaultGroovyPageObservationConvention implements GroovyPageObservationConvention { + + private static final String DEFAULT_NAME = "gsp.view"; + + private static final KeyValue ERROR_NONE = LowCardinalityKeyNames.ERROR.withValue("none"); + + private static final String VIEW_UNKNOWN = "unknown"; + + private final String name; + + public DefaultGroovyPageObservationConvention() { + this(DEFAULT_NAME); + } + + public DefaultGroovyPageObservationConvention(String name) { + this.name = name; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getContextualName(GroovyPageObservationContext context) { + return "gsp.view " + viewUri(context); + } + + @Override + public KeyValues getLowCardinalityKeyValues(GroovyPageObservationContext context) { + return KeyValues.of(view(context), error(context)); + } + + protected KeyValue view(GroovyPageObservationContext context) { + return LowCardinalityKeyNames.VIEW.withValue(viewUri(context)); + } + + protected KeyValue error(GroovyPageObservationContext context) { + Throwable error = context.getError(); + return (error != null) + ? LowCardinalityKeyNames.ERROR.withValue(error.getClass().getSimpleName()) + : ERROR_NONE; + } + + private static String viewUri(GroovyPageObservationContext context) { + String uri = context.getViewUri(); + return (uri != null && !uri.isEmpty()) ? uri : VIEW_UNKNOWN; + } +} diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java new file mode 100644 index 00000000000..c342f4d5b85 --- /dev/null +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java @@ -0,0 +1,46 @@ +/* + * 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.observation; + +import io.micrometer.observation.Observation; + +/** + * Context that holds information for {@link io.micrometer.observation.Observation} instrumentation + * of Groovy Server Pages (GSP) view rendering. + * + * @author Grails + * @since 8.0 + * @see GroovyPageObservationDocumentation + */ +public class GroovyPageObservationContext extends Observation.Context { + + private final String viewUri; + + public GroovyPageObservationContext(String viewUri) { + this.viewUri = viewUri; + } + + /** + * Return the URI of the GSP view being rendered (e.g. {@code /book/show}). + * @return the view URI, possibly {@code null} + */ + public String getViewUri() { + return this.viewUri; + } +} diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationConvention.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationConvention.java new file mode 100644 index 00000000000..b3e7a9088ff --- /dev/null +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/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.web.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/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java new file mode 100644 index 00000000000..7b1cb2afe05 --- /dev/null +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java @@ -0,0 +1,71 @@ +/* + * 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.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. + * + * @author Grails + * @since 8.0 + */ +public enum GroovyPageObservationDocumentation implements ObservationDocumentation { + + /** + * Observation created around the rendering of a single GSP view. + */ + GSP_VIEW { + @Override + public Class> getDefaultConvention() { + return DefaultGroovyPageObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * URI of the GSP view being rendered. + */ + VIEW { + @Override + public String asString() { + return "gsp.view"; + } + }, + + /** + * Simple name of the exception thrown during rendering, or {@code "none"}. + */ + ERROR { + @Override + public String asString() { + return "error"; + } + } + } +} 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..8f3eb3d6dfa 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 @@ -32,6 +32,9 @@ import org.springframework.core.io.Resource; import org.springframework.scripting.ScriptSource; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + import grails.util.Environment; import grails.util.GrailsUtil; import grails.web.pages.GroovyPagesUriService; @@ -39,6 +42,10 @@ import org.grails.gsp.GroovyPageWritable; import org.grails.gsp.GroovyPagesException; import org.grails.gsp.GroovyPagesTemplateEngine; +import org.grails.web.gsp.observation.DefaultGroovyPageObservationConvention; +import org.grails.web.gsp.observation.GroovyPageObservationContext; +import org.grails.web.gsp.observation.GroovyPageObservationConvention; +import org.grails.web.gsp.observation.GroovyPageObservationDocumentation; import org.grails.web.pages.GSPResponseWriter; import org.grails.web.servlet.mvc.GrailsWebRequest; @@ -68,9 +75,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(); + 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 +179,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..ba57b459a5a 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 @@ -32,6 +32,7 @@ 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; @@ -39,12 +40,15 @@ import org.springframework.web.servlet.view.AbstractUrlBasedView; import org.springframework.web.servlet.view.InternalResourceViewResolver; +import io.micrometer.observation.ObservationRegistry; + import grails.util.CacheEntry; import grails.util.GrailsStringUtils; import grails.util.GrailsUtil; import org.grails.gsp.GroovyPagesTemplateEngine; import org.grails.gsp.io.GroovyPageScriptSource; import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; +import org.grails.web.gsp.observation.GroovyPageObservationConvention; import org.grails.web.servlet.mvc.GrailsWebRequest; /** @@ -66,6 +70,8 @@ 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; /** * Constructor. @@ -221,6 +227,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 +240,38 @@ 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; + 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; + } + 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/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..4520acdb54b --- /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: + KeyValues kvs = recorded[0].lowCardinalityKeyValues + kvs.find { it.key == 'gsp.view' }?.value == '/book/show' + kvs.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' + } +} From 1708db9cf9efbb5d145186aa0ff3575e04bcaa29 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 01:19:13 -0700 Subject: [PATCH 02/12] Generalize GSP observation kit; instrument template rendering (gsp.template) Generalizes the observation kit so the view, included templates, and layouts share one Context/Convention/Documentation: GroovyPageObservationContext now carries a generic resource name; the documentation enum gains GSP_TEMPLATE and GSP_LAYOUT and uses uniform low-cardinality keys (gsp.name, error); DefaultGroovyPageObservationConvention is parameterized by observation name. Instruments GroovyPagesTemplateRenderer.render with a 'gsp.template' observation (observeChecked, since render throws IOException), tagged with the template name. The ObservationRegistry is @Autowired (required=false), defaulting to NOOP with a fast-path. gsp.view keeps working (now tagged gsp.name); GSP_LAYOUT is declared for the follow-up. --- .../web/gsp/GroovyPagesTemplateRenderer.java | 39 +++++++++++++++++ ...efaultGroovyPageObservationConvention.java | 30 +++++++------ .../GroovyPageObservationContext.java | 17 ++++---- .../GroovyPageObservationDocumentation.java | 42 ++++++++++++------- .../web/servlet/view/GroovyPageView.java | 2 +- .../view/GroovyPageViewObservationSpec.groovy | 2 +- 6 files changed, 92 insertions(+), 40 deletions(-) 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..2c3bf55c743 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,11 @@ import groovy.text.Template; import org.codehaus.groovy.runtime.InvokerHelper; +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; @@ -60,6 +64,10 @@ import org.grails.taglib.encoder.OutputEncodingSettings; import org.grails.taglib.encoder.WithCodecHelper; import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; +import org.grails.web.gsp.observation.DefaultGroovyPageObservationConvention; +import org.grails.web.gsp.observation.GroovyPageObservationContext; +import org.grails.web.gsp.observation.GroovyPageObservationConvention; +import org.grails.web.gsp.observation.GroovyPageObservationDocumentation; import org.grails.web.servlet.mvc.GrailsWebRequest; import org.grails.web.util.GrailsApplicationAttributes; @@ -84,6 +92,9 @@ 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; public void afterPropertiesSet() throws Exception { if (scaffoldingTemplateGenerator != null) { @@ -103,6 +114,22 @@ 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; + } + public void clearCache() { templateCache.clear(); } @@ -115,6 +142,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)); + } + + private 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"); diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java index 0025886eb9b..c282c9b807e 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java @@ -26,26 +26,24 @@ /** * Default {@link GroovyPageObservationConvention}. * - *

Names the observation {@code gsp.view} and attaches the {@code gsp.view} (view URI) - * and {@code error} (exception simple name, or {@code "none"}) low-cardinality key values.

+ *

Names the observation as configured ({@code gsp.view} / {@code gsp.template} / {@code gsp.layout}) + * and attaches the {@code gsp.name} (rendered resource) and {@code error} (exception simple name, or + * {@code "none"}) low-cardinality key values.

* * @author Grails * @since 8.0 */ public class DefaultGroovyPageObservationConvention implements GroovyPageObservationConvention { - private static final String DEFAULT_NAME = "gsp.view"; - private static final KeyValue ERROR_NONE = LowCardinalityKeyNames.ERROR.withValue("none"); - private static final String VIEW_UNKNOWN = "unknown"; + private static final String UNKNOWN = "unknown"; private final String name; - 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; } @@ -57,16 +55,16 @@ public String getName() { @Override public String getContextualName(GroovyPageObservationContext context) { - return "gsp.view " + viewUri(context); + return this.name + " " + resource(context); } @Override public KeyValues getLowCardinalityKeyValues(GroovyPageObservationContext context) { - return KeyValues.of(view(context), error(context)); + return KeyValues.of(name(context), error(context)); } - protected KeyValue view(GroovyPageObservationContext context) { - return LowCardinalityKeyNames.VIEW.withValue(viewUri(context)); + protected KeyValue name(GroovyPageObservationContext context) { + return LowCardinalityKeyNames.NAME.withValue(resource(context)); } protected KeyValue error(GroovyPageObservationContext context) { @@ -76,8 +74,8 @@ protected KeyValue error(GroovyPageObservationContext context) { : ERROR_NONE; } - private static String viewUri(GroovyPageObservationContext context) { - String uri = context.getViewUri(); - return (uri != null && !uri.isEmpty()) ? uri : VIEW_UNKNOWN; + private static String resource(GroovyPageObservationContext context) { + String resource = context.getResource(); + return (resource != null && !resource.isEmpty()) ? resource : UNKNOWN; } } diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java index c342f4d5b85..f3893609d4d 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java @@ -22,7 +22,7 @@ /** * Context that holds information for {@link io.micrometer.observation.Observation} instrumentation - * of Groovy Server Pages (GSP) view rendering. + * of Groovy Server Pages (GSP) rendering — the view, an included template, or a layout. * * @author Grails * @since 8.0 @@ -30,17 +30,18 @@ */ public class GroovyPageObservationContext extends Observation.Context { - private final String viewUri; + private final String resource; - public GroovyPageObservationContext(String viewUri) { - this.viewUri = viewUri; + public GroovyPageObservationContext(String resource) { + this.resource = resource; } /** - * Return the URI of the GSP view being rendered (e.g. {@code /book/show}). - * @return the view URI, possibly {@code null} + * 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 getViewUri() { - return this.viewUri; + public String getResource() { + return this.resource; } } diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java index 7b1cb2afe05..ed83a71eb9a 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java +++ b/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java @@ -26,35 +26,49 @@ /** * Documented {@link io.micrometer.observation.Observation}s for Groovy Server Pages (GSP) rendering. * + *

Each observation shares the same {@link LowCardinalityKeyNames key names} ({@code gsp.name}, + * {@code error}); the observation name ({@code gsp.view} / {@code gsp.template} / {@code gsp.layout}) + * distinguishes what was rendered.

+ * * @author Grails * @since 8.0 */ public enum GroovyPageObservationDocumentation implements ObservationDocumentation { /** - * Observation created around the rendering of a single GSP view. + * Rendering of a single GSP view. */ - GSP_VIEW { - @Override - public Class> getDefaultConvention() { - return DefaultGroovyPageObservationConvention.class; - } + GSP_VIEW, - @Override - public KeyName[] getLowCardinalityKeyNames() { - return LowCardinalityKeyNames.values(); - } - }; + /** + * Rendering of an included GSP template (e.g. {@code }). + */ + GSP_TEMPLATE, + + /** + * Decoration of rendered content by a GSP layout (SiteMesh). + */ + GSP_LAYOUT; + + @Override + public Class> getDefaultConvention() { + return DefaultGroovyPageObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } public enum LowCardinalityKeyNames implements KeyName { /** - * URI of the GSP view being rendered. + * Name of the rendered resource (view URI, template name, or layout name). */ - VIEW { + NAME { @Override public String asString() { - return "gsp.view"; + return "gsp.name"; } }, 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 8f3eb3d6dfa..cb88c949f42 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 @@ -75,7 +75,7 @@ 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(); + private static final GroovyPageObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.view"); private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; private GroovyPageObservationConvention observationConvention; 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 index 4520acdb54b..13977b84d91 100644 --- 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 @@ -79,7 +79,7 @@ class GroovyPageViewObservationSpec extends Specification { and: KeyValues kvs = recorded[0].lowCardinalityKeyValues - kvs.find { it.key == 'gsp.view' }?.value == '/book/show' + kvs.find { it.key == 'gsp.name' }?.value == '/book/show' kvs.find { it.key == 'error' }?.value == 'none' } From 497d2ed4520cf9a1619826484f8942df7593945b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 01:24:51 -0700 Subject: [PATCH 03/12] Instrument GSP layout decoration (gsp.layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the SiteMesh layout decoration (EmbeddedGrailsLayoutView -> decorator.render) in a 'gsp.layout' observation tagged with the layout page name, using the shared GSP observation kit. Only the decoration is wrapped (not obtainContent), so it does not double-count the inner gsp.view render — the two appear as sibling phases under the request trace. The ObservationRegistry is resolved in GrailsLayoutViewResolver from the refreshed application context and set on each GrailsLayoutView (NOOP fallback / fast-path). --- .../web/layout/EmbeddedGrailsLayoutView.java | 33 ++++++++++++++++++- .../web/layout/GrailsLayoutViewResolver.java | 9 ++++- 2 files changed, 40 insertions(+), 2 deletions(-) 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..498bac80e51 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 @@ -31,9 +31,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.http.MediaType; import org.springframework.web.servlet.View; +import org.grails.web.gsp.observation.DefaultGroovyPageObservationConvention; +import org.grails.web.gsp.observation.GroovyPageObservationContext; +import org.grails.web.gsp.observation.GroovyPageObservationConvention; +import org.grails.web.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 +55,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 +96,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 +114,26 @@ protected void renderTemplate(Map model, GrailsWebRequest webReq } + private 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; + } + 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..838259b7b75 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 @@ -31,6 +31,8 @@ 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; import org.springframework.context.event.ContextRefreshedEvent; @@ -47,6 +49,7 @@ 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; public GrailsLayoutViewResolver() { super(); @@ -58,7 +61,9 @@ public GrailsLayoutViewResolver(ViewResolver innerViewResolver, GroovyPageLayout @Override protected View createLayoutView(View innerView) { - return new GrailsLayoutView(groovyPageLayoutFinder, innerView, contentProcessor); + GrailsLayoutView layoutView = new GrailsLayoutView(groovyPageLayoutFinder, innerView, contentProcessor); + layoutView.setObservationRegistry(this.observationRegistry); + return layoutView; } public void init() { @@ -140,6 +145,8 @@ public void setOrder(int order) { @Override public void onApplicationEvent(ContextRefreshedEvent event) { + this.observationRegistry = event.getApplicationContext() + .getBeanProvider(ObservationRegistry.class).getIfAvailable(() -> ObservationRegistry.NOOP); init(); } } From 6a00f9907043ae62f1b05e22b14f96053d7a992b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 01:36:19 -0700 Subject: [PATCH 04/12] Relocate GSP observation kit to grails-gsp-core; instrument compilation (gsp.compile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the observation kit (Context/Convention/Documentation) from grails-web-gsp down to grails-gsp-core so the template engine — which lives in core, below the web layer, and which core cannot import upward from web-gsp — can use it too. View/template/layout importers are repointed to the new org.grails.gsp.observation package. Instruments GroovyPagesTemplateEngine.buildPageMetaInfo with a 'gsp.compile' observation (observeChecked). Compilation only happens on a template cache miss, so the observation's count is the compile/miss rate and its timer is the compile latency; tagged with the GSP page name. The ObservationRegistry is resolved from the application context (NOOP fallback). --- .../grails/gsp/GroovyPagesTemplateEngine.java | 24 +++++++++++++++++++ ...efaultGroovyPageObservationConvention.java | 4 ++-- .../GroovyPageObservationContext.java | 2 +- .../GroovyPageObservationConvention.java | 2 +- .../GroovyPageObservationDocumentation.java | 9 +++++-- .../web/layout/EmbeddedGrailsLayoutView.java | 8 +++---- .../web/gsp/GroovyPagesTemplateRenderer.java | 8 +++---- .../web/servlet/view/GroovyPageView.java | 8 +++---- .../servlet/view/GroovyPageViewResolver.java | 2 +- 9 files changed, 48 insertions(+), 19 deletions(-) rename grails-gsp/{grails-web-gsp/src/main/groovy/org/grails/web => core/src/main/groovy/org/grails}/gsp/observation/DefaultGroovyPageObservationConvention.java (95%) rename grails-gsp/{grails-web-gsp/src/main/groovy/org/grails/web => core/src/main/groovy/org/grails}/gsp/observation/GroovyPageObservationContext.java (97%) rename grails-gsp/{grails-web-gsp/src/main/groovy/org/grails/web => core/src/main/groovy/org/grails}/gsp/observation/GroovyPageObservationConvention.java (97%) rename grails-gsp/{grails-web-gsp/src/main/groovy/org/grails/web => core/src/main/groovy/org/grails}/gsp/observation/GroovyPageObservationDocumentation.java (93%) 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..456c3236142 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 @@ -58,6 +58,9 @@ import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.util.Assert; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + import grails.config.Config; import grails.config.Settings; import grails.core.GrailsApplication; @@ -70,6 +73,10 @@ import org.grails.core.exceptions.DefaultErrorsPrinter; import org.grails.exceptions.ExceptionUtils; import org.grails.gsp.compiler.GroovyPageParser; +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.gsp.io.DefaultGroovyPageLocator; import org.grails.gsp.io.GroovyPageCompiledScriptSource; import org.grails.gsp.io.GroovyPageLocator; @@ -108,6 +115,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 +491,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 +776,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/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java similarity index 95% rename from grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java rename to grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java index c282c9b807e..30ec3eae552 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/DefaultGroovyPageObservationConvention.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/DefaultGroovyPageObservationConvention.java @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.web.gsp.observation; +package org.grails.gsp.observation; import io.micrometer.common.KeyValue; import io.micrometer.common.KeyValues; -import static org.grails.web.gsp.observation.GroovyPageObservationDocumentation.LowCardinalityKeyNames; +import static org.grails.gsp.observation.GroovyPageObservationDocumentation.LowCardinalityKeyNames; /** * Default {@link GroovyPageObservationConvention}. diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java similarity index 97% rename from grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java rename to grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java index f3893609d4d..47597ca440f 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationContext.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationContext.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.web.gsp.observation; +package org.grails.gsp.observation; import io.micrometer.observation.Observation; diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationConvention.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java similarity index 97% rename from grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationConvention.java rename to grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java index b3e7a9088ff..e7967b070d8 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationConvention.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationConvention.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.web.gsp.observation; +package org.grails.gsp.observation; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationConvention; diff --git a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java similarity index 93% rename from grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java rename to grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java index ed83a71eb9a..061b8bbdaba 100644 --- a/grails-gsp/grails-web-gsp/src/main/groovy/org/grails/web/gsp/observation/GroovyPageObservationDocumentation.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.grails.web.gsp.observation; +package org.grails.gsp.observation; import io.micrometer.common.docs.KeyName; import io.micrometer.observation.Observation; @@ -48,7 +48,12 @@ public enum GroovyPageObservationDocumentation implements ObservationDocumentati /** * Decoration of rendered content by a GSP layout (SiteMesh). */ - GSP_LAYOUT; + GSP_LAYOUT, + + /** + * Compilation of a GSP into its {@code GroovyPageMetaInfo} (happens on a template cache miss). + */ + GSP_COMPILE; @Override public Class> getDefaultConvention() { 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 498bac80e51..7dd21ed53f1 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 @@ -37,10 +37,10 @@ import org.springframework.http.MediaType; import org.springframework.web.servlet.View; -import org.grails.web.gsp.observation.DefaultGroovyPageObservationConvention; -import org.grails.web.gsp.observation.GroovyPageObservationContext; -import org.grails.web.gsp.observation.GroovyPageObservationConvention; -import org.grails.web.gsp.observation.GroovyPageObservationDocumentation; +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; 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 2c3bf55c743..9bd5c24eec2 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 @@ -64,10 +64,10 @@ import org.grails.taglib.encoder.OutputEncodingSettings; import org.grails.taglib.encoder.WithCodecHelper; import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; -import org.grails.web.gsp.observation.DefaultGroovyPageObservationConvention; -import org.grails.web.gsp.observation.GroovyPageObservationContext; -import org.grails.web.gsp.observation.GroovyPageObservationConvention; -import org.grails.web.gsp.observation.GroovyPageObservationDocumentation; +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.mvc.GrailsWebRequest; import org.grails.web.util.GrailsApplicationAttributes; 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 cb88c949f42..b12e1c36f84 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 @@ -42,10 +42,10 @@ import org.grails.gsp.GroovyPageWritable; import org.grails.gsp.GroovyPagesException; import org.grails.gsp.GroovyPagesTemplateEngine; -import org.grails.web.gsp.observation.DefaultGroovyPageObservationConvention; -import org.grails.web.gsp.observation.GroovyPageObservationContext; -import org.grails.web.gsp.observation.GroovyPageObservationConvention; -import org.grails.web.gsp.observation.GroovyPageObservationDocumentation; +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; 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 ba57b459a5a..4ac4ad84dc0 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 @@ -48,7 +48,7 @@ import org.grails.gsp.GroovyPagesTemplateEngine; import org.grails.gsp.io.GroovyPageScriptSource; import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; -import org.grails.web.gsp.observation.GroovyPageObservationConvention; +import org.grails.gsp.observation.GroovyPageObservationConvention; import org.grails.web.servlet.mvc.GrailsWebRequest; /** From b52b85693ab2c14e9e7989b51c00ec5c23f78d6c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 01:42:46 -0700 Subject: [PATCH 05/12] Add GSP template-cache hit/miss metrics (gsp.template.cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gsp.template.cache{result=hit|miss} counters to GroovyPagesTemplateEngine, incremented per cacheable createTemplate() call based on whether the compiled-page cache already held the entry — giving the cache hit ratio alongside the gsp.compile (cache-miss) timer. Counters are meter-only (unlike observations), so this resolves a MeterRegistry from the application context and no-ops when absent. Adds micrometer-core as a BOM-managed implementation dependency of grails-gsp-core; it is runtime-present in every Spring Boot app (and runtime-transitive to consumers), so there is no class-load risk. --- grails-gsp/core/build.gradle | 2 ++ .../grails/gsp/GroovyPagesTemplateEngine.java | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) 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 456c3236142..599c9c6fa04 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 @@ -58,6 +58,8 @@ import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.util.Assert; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; @@ -117,6 +119,8 @@ public class GroovyPagesTemplateEngine extends ResourceAwareTemplateEngine imple private static final GroovyPageObservationConvention COMPILE_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.compile"); private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + private Counter cacheHits; + private Counter cacheMisses; private GroovyPageLocator groovyPageLocator = new DefaultGroovyPageLocator(); @@ -308,6 +312,7 @@ public Template createTemplate(Resource resource, final boolean cacheable) { protected Template createTemplate(Resource resource, final String pageName, final boolean cacheable) throws IOException { GroovyPageMetaInfo meta; if (cacheable) { + recordCacheAccess(pageCache.containsKey(pageName)); meta = CacheEntry.getValue(pageCache, pageName, -1, null, new GroovyPagesTemplateEngineCallable(new GroovyPagesTemplateEngineCacheEntry(pageName)), true, resource); @@ -778,6 +783,20 @@ public void setApplicationContext(ApplicationContext applicationContext) throws } this.observationRegistry = applicationContext.getBeanProvider(ObservationRegistry.class) .getIfAvailable(() -> ObservationRegistry.NOOP); + MeterRegistry meterRegistry = applicationContext.getBeanProvider(MeterRegistry.class).getIfAvailable(); + if (meterRegistry != null) { + this.cacheHits = Counter.builder("gsp.template.cache") + .tag("result", "hit").description("GSP compiled-template cache hits").register(meterRegistry); + this.cacheMisses = Counter.builder("gsp.template.cache") + .tag("result", "miss").description("GSP compiled-template cache misses").register(meterRegistry); + } + } + + private void recordCacheAccess(boolean hit) { + Counter counter = hit ? this.cacheHits : this.cacheMisses; + if (counter != null) { + counter.increment(); + } } /** From 82606ec022cdd49792e68e7b32f6a372c7014904 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 01:47:23 -0700 Subject: [PATCH 06/12] Test gsp.compile observation and gsp.template.cache hit/miss GroovyPagesTemplateEngineObservationSpec overrides the real GSP compilation with a stub and wires a recording ObservationRegistry + a SimpleMeterRegistry, then asserts: - compiling a page records a 'gsp.compile' observation tagged with the page name - two cacheable requests for the same page produce one miss + one hit (and exactly one compile), exercising the gsp.template.cache counters --- ...yPagesTemplateEngineObservationSpec.groovy | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy 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..09ed3dd4190 --- /dev/null +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy @@ -0,0 +1,104 @@ +/* + * 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.common.KeyValues +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +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 and {@code gsp.template.cache} hit/miss counters on + * {@link GroovyPagesTemplateEngine}. The real GSP compilation is overridden so the test stays a unit. + */ +class GroovyPagesTemplateEngineObservationSpec extends Specification { + + private List recorded = [] + private SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry() + + /** Engine whose actual compilation returns a stub, wired with a recording registry + meter 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.getBeanFactory().registerSingleton('simpleMeterRegistry', meterRegistry) + ctx.refresh() + engine.setApplicationContext(ctx) + return engine + } + + private Resource gsp() { + new ByteArrayResource('hi'.bytes) + } + + private double cacheCount(String result) { + def c = meterRegistry.find('gsp.template.cache').tag('result', result).counter() + c != null ? c.count() : 0d + } + + 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: + KeyValues kvs = recorded[0].lowCardinalityKeyValues + kvs.find { it.key == 'gsp.name' }?.value == '/book/show' + kvs.find { it.key == 'error' }?.value == 'none' + } + + void "the template cache records a miss then a hit for the same page"() { + given: + GroovyPagesTemplateEngine engine = engine() + Resource resource = gsp() + + when: "the page is requested twice (cacheable)" + engine.createTemplate(resource, '/book/list', true) + engine.createTemplate(resource, '/book/list', true) + + then: "first access misses (and compiles), second hits" + cacheCount('miss') == 1d + cacheCount('hit') == 1d + recorded.count { it.name == 'gsp.compile' } == 1 + } +} From e911790bb2136b9dc764c786f991e0a3139e9f0f Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 17:25:40 -0700 Subject: [PATCH 07/12] Address review: instantiable default convention + accurate cache hit/miss - DefaultGroovyPageObservationConvention gets a no-arg constructor (name 'gsp') so it honors ObservationDocumentation.getDefaultConvention() reflectively; instrumentation sites still pass an explicitly-named instance. - GSP template cache hit/miss is now counted from whether a compile actually ran (a ThreadLocal flag set in buildPageMetaInfo) instead of pageCache.containsKey(), which mis-counted expired/reloaded entries as hits and could race on cold start. --- .../org/grails/gsp/GroovyPagesTemplateEngine.java | 11 ++++++++++- .../DefaultGroovyPageObservationConvention.java | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 599c9c6fa04..074d0e3734b 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 @@ -121,6 +121,9 @@ public class GroovyPagesTemplateEngine extends ResourceAwareTemplateEngine imple private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; private Counter cacheHits; private Counter cacheMisses; + // Set by buildPageMetaInfo (a compile) so the cacheable path can distinguish a true cache hit + // (no compile) from a miss/recompile accurately, rather than an inexact pageCache.containsKey() check. + private final ThreadLocal compiledOnThread = ThreadLocal.withInitial(() -> Boolean.FALSE); private GroovyPageLocator groovyPageLocator = new DefaultGroovyPageLocator(); @@ -312,10 +315,13 @@ public Template createTemplate(Resource resource, final boolean cacheable) { protected Template createTemplate(Resource resource, final String pageName, final boolean cacheable) throws IOException { GroovyPageMetaInfo meta; if (cacheable) { - recordCacheAccess(pageCache.containsKey(pageName)); + this.compiledOnThread.set(Boolean.FALSE); meta = CacheEntry.getValue(pageCache, pageName, -1, null, new GroovyPagesTemplateEngineCallable(new GroovyPagesTemplateEngineCacheEntry(pageName)), true, resource); + // A hit is a cacheable lookup that did NOT recompile (CacheEntry served a live entry); + // buildPageMetaInfo flips the flag whenever it actually compiled (cold or expired). + recordCacheAccess(!this.compiledOnThread.get()); } else { meta = buildPageMetaInfo(resource, pageName); } @@ -496,6 +502,9 @@ public Template createTemplate(InputStream inputStream) { } protected GroovyPageMetaInfo buildPageMetaInfo(Resource resource, String pageName) throws IOException { + // Mark that a compile occurred on this thread so the cacheable createTemplate() path can + // count an accurate hit/miss (a recompile of an expired entry is a miss, not a hit). + this.compiledOnThread.set(Boolean.TRUE); if (this.observationRegistry.isNoop()) { return doBuildPageMetaInfo(resource, pageName); } 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 index 30ec3eae552..8060d501694 100644 --- 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 @@ -39,8 +39,19 @@ public class DefaultGroovyPageObservationConvention implements GroovyPageObserva 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}) */ From 2f77a8e6fefaeace74f3aea675201ac91e9611a8 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 18:27:40 -0700 Subject: [PATCH 08/12] Wire layout observation convention through; make template renderer testable - GrailsLayoutViewResolver now propagates a custom GroovyPageObservationConvention to the GrailsLayoutView it builds (previously the field on the view had no setter, so a configured convention was silently dropped for gsp.layout observations). - EmbeddedGrailsLayoutView gains setObservationConvention to receive it. - GroovyPagesTemplateRenderer.doRender is now protected so it can be overridden in tests, isolating the gsp.template observation wrapper from the render pipeline. - Add GroovyPagesTemplateRendererObservationSpec covering the success, NOOP, and error paths (mirrors GroovyPageViewObservationSpec). --- .../web/layout/EmbeddedGrailsLayoutView.java | 8 ++ .../web/layout/GrailsLayoutViewResolver.java | 9 ++ .../web/gsp/GroovyPagesTemplateRenderer.java | 2 +- ...agesTemplateRendererObservationSpec.groovy | 109 ++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 grails-gsp/grails-web-gsp/src/test/groovy/org/grails/web/gsp/GroovyPagesTemplateRendererObservationSpec.groovy 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 7dd21ed53f1..3f3626568fe 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 @@ -134,6 +134,14 @@ 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 838259b7b75..b12e6ce3440 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 @@ -50,11 +50,19 @@ public class GrailsLayoutViewResolver extends EmbeddedGrailsLayoutViewResolver i 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); } @@ -63,6 +71,7 @@ public GrailsLayoutViewResolver(ViewResolver innerViewResolver, GroovyPageLayout protected View createLayoutView(View innerView) { GrailsLayoutView layoutView = new GrailsLayoutView(groovyPageLayoutFinder, innerView, contentProcessor); layoutView.setObservationRegistry(this.observationRegistry); + layoutView.setObservationConvention(this.observationConvention); return layoutView; } 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 9bd5c24eec2..9fc64ef9f26 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 @@ -152,7 +152,7 @@ public void render(GrailsWebRequest webRequest, TemplateVariableBinding pageScop observation.observeChecked(() -> doRender(templateName, webRequest, pageScope, attrs, body, out)); } - private void doRender(String templateName, GrailsWebRequest webRequest, TemplateVariableBinding pageScope, + 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"); 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..ee688882edf --- /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: + KeyValues kvs = recorded[0].lowCardinalityKeyValues + kvs.find { it.key == 'gsp.name' }?.value == '/shared/_card' + kvs.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' + } +} From 36f657ca6d9edb67d6803fd379f70b109dd255dd Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 19:05:47 -0700 Subject: [PATCH 09/12] Fix GSP cache hit/miss accuracy under reentrant compilation; add layout test Review follow-ups: - GroovyPagesTemplateEngine: replace the thread-local 'compiledOnThread' flag (used to tell a cache hit from a recompile) with a per-call PageCompileRequest carried through CacheEntry's cacheRequestObject. The thread-local mis-counted when template creation re-entered on the same thread (an inner cache hit reset the flag, so the outer lookup that actually compiled was recorded as a hit), and it also lingered on pooled request threads (never removed). The per-call holder is reentrancy-safe and leak-free; updateValue (the only cacheable compile path) sets compiled=true. Add a regression test for the reentrant-hit case. - EmbeddedGrailsLayoutView.renderWithLayout is now protected so the gsp.layout observation can be unit-tested in isolation. Add EmbeddedGrailsLayoutViewObservationSpec (success / NOOP / error paths), mirroring the view and template renderer specs. Add byte-buddy to the layout module's test runtime so Spock can mock concrete collaborators (consistent with grails-web-gsp/grails-sitemesh3). --- .../grails/gsp/GroovyPagesTemplateEngine.java | 38 +++-- ...yPagesTemplateEngineObservationSpec.groovy | 29 +++- grails-gsp/grails-layout/build.gradle | 1 + .../web/layout/EmbeddedGrailsLayoutView.java | 2 +- ...ddedGrailsLayoutViewObservationSpec.groovy | 133 ++++++++++++++++++ 5 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/EmbeddedGrailsLayoutViewObservationSpec.groovy 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 074d0e3734b..798334a07bc 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 @@ -121,9 +121,6 @@ public class GroovyPagesTemplateEngine extends ResourceAwareTemplateEngine imple private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; private Counter cacheHits; private Counter cacheMisses; - // Set by buildPageMetaInfo (a compile) so the cacheable path can distinguish a true cache hit - // (no compile) from a miss/recompile accurately, rather than an inexact pageCache.containsKey() check. - private final ThreadLocal compiledOnThread = ThreadLocal.withInitial(() -> Boolean.FALSE); private GroovyPageLocator groovyPageLocator = new DefaultGroovyPageLocator(); @@ -158,7 +155,7 @@ public GroovyPagesTemplateEngineCacheEntry(String pageName) { @Override protected boolean hasExpired(long timeout, Object cacheRequestObject) { GroovyPageMetaInfo meta = getValue(); - Resource resource = (Resource) cacheRequestObject; + Resource resource = ((PageCompileRequest) cacheRequestObject).resource; return meta == null || isGroovyPageReloadable(resource, meta); } @@ -168,8 +165,26 @@ protected GroovyPageMetaInfo updateValue(GroovyPageMetaInfo oldValue, Callable() { @Override boolean supportsContext(Observation.Context context) { true } @@ -50,6 +50,10 @@ class GroovyPagesTemplateEngineObservationSpec extends Specification { GroovyPagesTemplateEngine engine = new GroovyPagesTemplateEngine() { @Override protected GroovyPageMetaInfo buildPageMetaInfo(InputStream inputStream, Resource res, String pageName) { + // Let a test re-enter createTemplate mid-compile to exercise the reentrancy path. + if (compileHook != null) { + compileHook.call(pageName) + } return new GroovyPageMetaInfo() } } @@ -101,4 +105,27 @@ class GroovyPagesTemplateEngineObservationSpec extends Specification { cacheCount('hit') == 1d recorded.count { it.name == 'gsp.compile' } == 1 } + + void "a reentrant cache hit during compilation does not mask the outer miss"() { + given: "an engine that, while compiling the outer page, re-renders an already-cached inner page" + Resource innerResource = gsp() + List engineRef = [] + GroovyPagesTemplateEngine engine = engine({ String pageName -> + if (pageName == '/page/outer') { + engineRef[0].createTemplate(innerResource, '/layout/inner', true) + } + }) + engineRef << engine + + and: "the inner page is already cached, so its reentrant lookup will be a hit" + engine.createTemplate(innerResource, '/layout/inner', true) + + when: "the outer page is compiled (a miss) and re-enters the cached inner lookup mid-compile" + engine.createTemplate(gsp(), '/page/outer', true) + + then: "the outer compile is still counted as a miss; only the reentrant inner lookup is a hit" + cacheCount('miss') == 2d + cacheCount('hit') == 1d + recorded.count { it.name == 'gsp.compile' } == 2 + } } 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 3f3626568fe..3f00b39a295 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 @@ -114,7 +114,7 @@ protected void renderTemplate(Map model, GrailsWebRequest webReq } - private void renderWithLayout(SpringMVCViewDecorator decorator, Content content, Map model, + 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()); 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..6d57859977a --- /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: + KeyValues kvs = recorded[0].lowCardinalityKeyValues + kvs.find { it.key == 'gsp.name' }?.value == '/layouts/main' + kvs.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' + } +} From eb8767d5540a24143052cb21f1f58832803bdc28 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 5 Jun 2026 19:09:56 -0700 Subject: [PATCH 10/12] Note the intentional benign race in GroovyPageViewResolver registry resolution --- .../org/grails/web/servlet/view/GroovyPageViewResolver.java | 3 +++ 1 file changed, 3 insertions(+) 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 4ac4ad84dc0..f893dc5bab5 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 @@ -252,6 +252,9 @@ private ObservationRegistry resolveObservationRegistry() { 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; From be09b48323adcbbea5df86ec347f31aff51d24c4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sat, 6 Jun 2026 11:03:37 -0700 Subject: [PATCH 11/12] Fix checkstyle import order and operator wrap violations in GSP observability --- .../grails/gsp/GroovyPagesTemplateEngine.java | 17 ++++++++--------- .../DefaultGroovyPageObservationConvention.java | 6 +++--- .../web/layout/EmbeddedGrailsLayoutView.java | 5 ++--- .../web/layout/GrailsLayoutViewResolver.java | 1 - .../web/gsp/GroovyPagesTemplateRenderer.java | 8 ++++---- .../grails/web/servlet/view/GroovyPageView.java | 5 ++--- .../servlet/view/GroovyPageViewResolver.java | 11 +++++------ 7 files changed, 24 insertions(+), 29 deletions(-) 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 798334a07bc..b2ed91a93dd 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,10 @@ import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.runtime.IOGroovyMethods; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -58,11 +62,6 @@ import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.util.Assert; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.observation.Observation; -import io.micrometer.observation.ObservationRegistry; - import grails.config.Config; import grails.config.Settings; import grails.core.GrailsApplication; @@ -75,16 +74,16 @@ import org.grails.core.exceptions.DefaultErrorsPrinter; import org.grails.exceptions.ExceptionUtils; import org.grails.gsp.compiler.GroovyPageParser; -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.gsp.io.DefaultGroovyPageLocator; import org.grails.gsp.io.GroovyPageCompiledScriptSource; import org.grails.gsp.io.GroovyPageLocator; 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; /** 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 index 8060d501694..3980f66b0e5 100644 --- 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 @@ -80,9 +80,9 @@ protected KeyValue name(GroovyPageObservationContext context) { protected KeyValue error(GroovyPageObservationContext context) { Throwable error = context.getError(); - return (error != null) - ? LowCardinalityKeyNames.ERROR.withValue(error.getClass().getSimpleName()) - : ERROR_NONE; + return (error != null) ? + LowCardinalityKeyNames.ERROR.withValue(error.getClass().getSimpleName()) : + ERROR_NONE; } private static String resource(GroovyPageObservationContext context) { 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 3f00b39a295..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,11 +28,10 @@ import com.opensymphony.module.sitemesh.RequestConstants; import com.opensymphony.sitemesh.Content; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - 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; 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 b12e6ce3440..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,7 +30,6 @@ 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; 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 9fc64ef9f26..0311520bcb0 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 @@ -58,16 +58,16 @@ 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.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; import org.grails.taglib.encoder.OutputEncodingSettings; import org.grails.taglib.encoder.WithCodecHelper; import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; -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.mvc.GrailsWebRequest; import org.grails.web.util.GrailsApplicationAttributes; 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 b12e1c36f84..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,15 +26,14 @@ 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; import org.springframework.core.io.Resource; import org.springframework.scripting.ScriptSource; -import io.micrometer.observation.Observation; -import io.micrometer.observation.ObservationRegistry; - import grails.util.Environment; import grails.util.GrailsUtil; import grails.web.pages.GroovyPagesUriService; 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 f893dc5bab5..b43f9df9ee9 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,6 +27,7 @@ import jakarta.servlet.http.HttpServletRequest; +import io.micrometer.observation.ObservationRegistry; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -40,15 +41,13 @@ import org.springframework.web.servlet.view.AbstractUrlBasedView; import org.springframework.web.servlet.view.InternalResourceViewResolver; -import io.micrometer.observation.ObservationRegistry; - import grails.util.CacheEntry; import grails.util.GrailsStringUtils; import grails.util.GrailsUtil; import org.grails.gsp.GroovyPagesTemplateEngine; import org.grails.gsp.io.GroovyPageScriptSource; -import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; import org.grails.gsp.observation.GroovyPageObservationConvention; +import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator; import org.grails.web.servlet.mvc.GrailsWebRequest; /** @@ -249,9 +248,9 @@ 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; + 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. From 36a810136f9253f7063fa0e2e9538287d7b8955b Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sat, 6 Jun 2026 20:12:48 -0700 Subject: [PATCH 12/12] GSP observability: track production caches, not the dev compile cache; gsp.name high-cardinality The gsp.template.cache hit/miss counter lived on GroovyPagesTemplateEngine's runtime-compile cache, which is development-only: a precompiled production deployment serves GSPs from AOT-compiled classes and bypasses that cache, so it can never register a hit there (and the higher layers intercept any second lookup). Move hit/miss to the caches actually consulted on the request path in a deployed app -- GroovyPagesTemplateRenderer.templateCache (cache=template) and GroovyPageViewResolver.viewCache (cache=view) -- as the gsp.cache{cache,result} counter (new GroovyPageCacheMetrics helper). Make gsp.name high-cardinality (span attribute) instead of low-cardinality (metric tag): on an app with many GSPs it exploded the render-timer series count. error remains the only low-cardinality tag. gsp.view/gsp.template/gsp.layout render timers are unchanged. gsp.compile is kept and documented as a dev/cold-start signal -- its presence on a precompiled production deployment flags a view that is not precompiled. Adds api dependency io.micrometer:micrometer-core to grails-web-gsp (MeterRegistry now appears in its setMeterRegistry ABI). --- .../grails/gsp/GroovyPagesTemplateEngine.java | 48 +--------- ...efaultGroovyPageObservationConvention.java | 18 ++-- .../observation/GroovyPageCacheMetrics.java | 88 +++++++++++++++++++ .../GroovyPageObservationDocumentation.java | 42 ++++++--- ...yPagesTemplateEngineObservationSpec.groovy | 72 +++------------ ...ddedGrailsLayoutViewObservationSpec.groovy | 8 +- grails-gsp/grails-web-gsp/build.gradle | 1 + .../web/gsp/GroovyPagesTemplateRenderer.java | 26 +++++- .../servlet/view/GroovyPageViewResolver.java | 19 ++++ ...agesTemplateRendererObservationSpec.groovy | 8 +- .../view/GroovyPageViewObservationSpec.groovy | 8 +- 11 files changed, 205 insertions(+), 133 deletions(-) create mode 100644 grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageCacheMetrics.java 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 b2ed91a93dd..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,8 +40,6 @@ import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.runtime.IOGroovyMethods; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import org.apache.commons.logging.Log; @@ -118,8 +116,6 @@ public class GroovyPagesTemplateEngine extends ResourceAwareTemplateEngine imple private static final GroovyPageObservationConvention COMPILE_OBSERVATION_CONVENTION = new DefaultGroovyPageObservationConvention("gsp.compile"); private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; - private Counter cacheHits; - private Counter cacheMisses; private GroovyPageLocator groovyPageLocator = new DefaultGroovyPageLocator(); @@ -154,7 +150,7 @@ public GroovyPagesTemplateEngineCacheEntry(String pageName) { @Override protected boolean hasExpired(long timeout, Object cacheRequestObject) { GroovyPageMetaInfo meta = getValue(); - Resource resource = ((PageCompileRequest) cacheRequestObject).resource; + Resource resource = (Resource) cacheRequestObject; return meta == null || isGroovyPageReloadable(resource, meta); } @@ -164,26 +160,8 @@ protected GroovyPageMetaInfo updateValue(GroovyPageMetaInfo oldValue, Callable ObservationRegistry.NOOP); - MeterRegistry meterRegistry = applicationContext.getBeanProvider(MeterRegistry.class).getIfAvailable(); - if (meterRegistry != null) { - this.cacheHits = Counter.builder("gsp.template.cache") - .tag("result", "hit").description("GSP compiled-template cache hits").register(meterRegistry); - this.cacheMisses = Counter.builder("gsp.template.cache") - .tag("result", "miss").description("GSP compiled-template cache misses").register(meterRegistry); - } - } - - private void recordCacheAccess(boolean hit) { - Counter counter = hit ? this.cacheHits : this.cacheMisses; - if (counter != null) { - counter.increment(); - } } /** 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 index 3980f66b0e5..810ccc205d7 100644 --- 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 @@ -21,14 +21,17 @@ 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}) - * and attaches the {@code gsp.name} (rendered resource) and {@code error} (exception simple name, or - * {@code "none"}) low-cardinality key values.

+ *

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 @@ -71,11 +74,16 @@ public String getContextualName(GroovyPageObservationContext context) { @Override public KeyValues getLowCardinalityKeyValues(GroovyPageObservationContext context) { - return KeyValues.of(name(context), error(context)); + return KeyValues.of(error(context)); + } + + @Override + public KeyValues getHighCardinalityKeyValues(GroovyPageObservationContext context) { + return KeyValues.of(name(context)); } protected KeyValue name(GroovyPageObservationContext context) { - return LowCardinalityKeyNames.NAME.withValue(resource(context)); + return HighCardinalityKeyNames.NAME.withValue(resource(context)); } protected KeyValue error(GroovyPageObservationContext context) { 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/GroovyPageObservationDocumentation.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/observation/GroovyPageObservationDocumentation.java index 061b8bbdaba..e9c07452041 100644 --- 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 @@ -26,9 +26,17 @@ /** * Documented {@link io.micrometer.observation.Observation}s for Groovy Server Pages (GSP) rendering. * - *

Each observation shares the same {@link LowCardinalityKeyNames key names} ({@code gsp.name}, - * {@code error}); the observation name ({@code gsp.view} / {@code gsp.template} / {@code gsp.layout}) - * distinguishes what was rendered.

+ *

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 @@ -51,7 +59,9 @@ public enum GroovyPageObservationDocumentation implements ObservationDocumentati GSP_LAYOUT, /** - * Compilation of a GSP into its {@code GroovyPageMetaInfo} (happens on a template cache miss). + * 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; @@ -65,25 +75,35 @@ public KeyName[] getLowCardinalityKeyNames() { return LowCardinalityKeyNames.values(); } + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + public enum LowCardinalityKeyNames implements KeyName { /** - * Name of the rendered resource (view URI, template name, or layout name). + * Simple name of the exception thrown during rendering, or {@code "none"}. */ - NAME { + ERROR { @Override public String asString() { - return "gsp.name"; + return "error"; } - }, + } + } + + public enum HighCardinalityKeyNames implements KeyName { /** - * Simple name of the exception thrown during rendering, or {@code "none"}. + * 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. */ - ERROR { + NAME { @Override public String asString() { - return "error"; + 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 index a38444c9389..5fd6db1b129 100644 --- a/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy +++ b/grails-gsp/core/src/test/groovy/org/grails/gsp/GroovyPagesTemplateEngineObservationSpec.groovy @@ -18,8 +18,6 @@ */ package org.grails.gsp -import io.micrometer.common.KeyValues -import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.micrometer.observation.Observation import io.micrometer.observation.ObservationHandler import io.micrometer.observation.ObservationRegistry @@ -31,16 +29,20 @@ import org.springframework.core.io.Resource import spock.lang.Specification /** - * Tests the {@code gsp.compile} observation and {@code gsp.template.cache} hit/miss counters on - * {@link GroovyPagesTemplateEngine}. The real GSP compilation is overridden so the test stays a unit. + * 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 = [] - private SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry() - /** Engine whose actual compilation returns a stub, wired with a recording registry + meter registry. */ - private GroovyPagesTemplateEngine engine(Closure compileHook = null) { + /** 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 } @@ -50,10 +52,6 @@ class GroovyPagesTemplateEngineObservationSpec extends Specification { GroovyPagesTemplateEngine engine = new GroovyPagesTemplateEngine() { @Override protected GroovyPageMetaInfo buildPageMetaInfo(InputStream inputStream, Resource res, String pageName) { - // Let a test re-enter createTemplate mid-compile to exercise the reentrancy path. - if (compileHook != null) { - compileHook.call(pageName) - } return new GroovyPageMetaInfo() } } @@ -61,7 +59,6 @@ class GroovyPagesTemplateEngineObservationSpec extends Specification { GenericApplicationContext ctx = new GenericApplicationContext() ctx.getBeanFactory().registerSingleton('observationRegistry', observationRegistry) - ctx.getBeanFactory().registerSingleton('simpleMeterRegistry', meterRegistry) ctx.refresh() engine.setApplicationContext(ctx) return engine @@ -71,11 +68,6 @@ class GroovyPagesTemplateEngineObservationSpec extends Specification { new ByteArrayResource('hi'.bytes) } - private double cacheCount(String result) { - def c = meterRegistry.find('gsp.template.cache').tag('result', result).counter() - c != null ? c.count() : 0d - } - void "compiling a page records a gsp.compile observation tagged with the page name"() { when: engine().buildPageMetaInfo(gsp(), '/book/show') @@ -85,47 +77,9 @@ class GroovyPagesTemplateEngineObservationSpec extends Specification { recorded[0].name == 'gsp.compile' recorded[0].contextualName == 'gsp.compile /book/show' - and: - KeyValues kvs = recorded[0].lowCardinalityKeyValues - kvs.find { it.key == 'gsp.name' }?.value == '/book/show' - kvs.find { it.key == 'error' }?.value == 'none' - } - - void "the template cache records a miss then a hit for the same page"() { - given: - GroovyPagesTemplateEngine engine = engine() - Resource resource = gsp() - - when: "the page is requested twice (cacheable)" - engine.createTemplate(resource, '/book/list', true) - engine.createTemplate(resource, '/book/list', true) - - then: "first access misses (and compiles), second hits" - cacheCount('miss') == 1d - cacheCount('hit') == 1d - recorded.count { it.name == 'gsp.compile' } == 1 - } - - void "a reentrant cache hit during compilation does not mask the outer miss"() { - given: "an engine that, while compiling the outer page, re-renders an already-cached inner page" - Resource innerResource = gsp() - List engineRef = [] - GroovyPagesTemplateEngine engine = engine({ String pageName -> - if (pageName == '/page/outer') { - engineRef[0].createTemplate(innerResource, '/layout/inner', true) - } - }) - engineRef << engine - - and: "the inner page is already cached, so its reentrant lookup will be a hit" - engine.createTemplate(innerResource, '/layout/inner', true) - - when: "the outer page is compiled (a miss) and re-enters the cached inner lookup mid-compile" - engine.createTemplate(gsp(), '/page/outer', true) - - then: "the outer compile is still counted as a miss; only the reentrant inner lookup is a hit" - cacheCount('miss') == 2d - cacheCount('hit') == 1d - recorded.count { it.name == 'gsp.compile' } == 2 + 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/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 index 6d57859977a..beb8d77214d 100644 --- 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 @@ -96,10 +96,10 @@ class EmbeddedGrailsLayoutViewObservationSpec extends Specification { recorded[0].name == 'gsp.layout' recorded[0].contextualName == 'gsp.layout /layouts/main' - and: - KeyValues kvs = recorded[0].lowCardinalityKeyValues - kvs.find { it.key == 'gsp.name' }?.value == '/layouts/main' - kvs.find { it.key == 'error' }?.value == 'none' + 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)"() { 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 0311520bcb0..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,6 +33,7 @@ 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; @@ -59,6 +60,7 @@ 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; @@ -95,6 +97,7 @@ public class GroovyPagesTemplateRenderer implements InitializingBean { 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) { @@ -130,6 +133,16 @@ public void setObservationConvention(GroovyPageObservationConvention observation 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(); } @@ -197,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