From 99b3b752468fb81ddc3aa063b0b31147cb508dda Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:03:30 -0500 Subject: [PATCH 01/47] feedback: Server-Timing -> Server Timing where possible --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/SECURITY.md | 4 +- .skills/example-apps.md | 4 +- AGENTS.md | 2 +- README.md | 5 +- docs/src/docs/browser-tools.adoc | 16 ++--- docs/src/docs/configuration.adoc | 2 +- docs/src/docs/how-it-works.adoc | 6 +- docs/src/docs/index.tmpl | 2 +- docs/src/docs/introduction.adoc | 10 +-- docs/src/docs/specification.adoc | 8 +-- .../app1/ServerTimingTestController.groovy | 8 +-- .../serverTimingTest/fastActionSlowView.gsp | 2 +- .../serverTimingTest/multipleOperations.gsp | 2 +- .../views/serverTimingTest/slowAction.gsp | 2 +- .../serverTimingTest/slowActionSlowView.gsp | 2 +- .../app1/ServerTimingIntegrationSpec.groovy | 64 +++++++++---------- .../ServerTimingDisabledTestController.groovy | 2 +- ...ServerTimingDisabledIntegrationSpec.groovy | 26 ++++---- .../GrailsServerTimingGrailsPlugin.groovy | 8 +-- .../servertiming/ServerTimingFilter.groovy | 4 +- .../ServerTimingResponseWrapper.groovy | 4 +- .../plugins/servertiming/core/Metric.groovy | 2 +- .../servertiming/core/TimingMetric.groovy | 2 +- .../plugins/servertiming/MetricSpec.groovy | 6 +- .../servertiming/TimingMetricSpec.groovy | 2 +- 26 files changed, 98 insertions(+), 99 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 475a9a1..1d174d9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -41,7 +41,7 @@ body: id: actual attributes: label: Actual Behavior - description: What actually happened? Include the Server-Timing header value if relevant. + description: What actually happened? Include the `Server-Timing` header value if relevant. validations: required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 97e5289..0413768 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -22,7 +22,7 @@ using [GitHub's private vulnerability reporting](https://github.com/grails-plugi ## Security Considerations -This plugin injects `Server-Timing` headers that expose server-side timing information. By default, the plugin is * +This plugin injects `Server-Timing` header that expose server-side timing information. By default, the plugin is * *disabled in production** to mitigate the risk of [timing attacks](https://w3c.github.io/server-timing/#security-considerations). @@ -30,7 +30,7 @@ If you enable the plugin in production, be aware that: - Timing data may help attackers infer information about server-side operations (e.g., whether a database lookup found a record) -- Cross-origin access to `Server-Timing` data requires the `Timing-Allow-Origin` header, which this plugin does **not** +- Cross-origin access to Server Timing data requires the `Timing-Allow-Origin` header, which this plugin does **not** set automatically See the [W3C Server Timing Security Considerations](https://w3c.github.io/server-timing/#security-considerations) for diff --git a/.skills/example-apps.md b/.skills/example-apps.md index cebd7c2..5685594 100644 --- a/.skills/example-apps.md +++ b/.skills/example-apps.md @@ -143,7 +143,7 @@ class ServerTimingIntegrationSpec extends Specification { restTemplate.exchange("${baseUrl}${path}", HttpMethod.GET, null, String) } - void "fast action should include Server-Timing header"() { + void "fast action should include Server Timing header"() { when: ResponseEntity response = doGet('/serverTimingTest/fast') @@ -162,7 +162,7 @@ class ServerTimingIntegrationSpec extends Specification { - Timing values are within expected ranges (e.g., slow action >= 200ms) - Different response types (GSP views, JSON, plain text) all include headers - Static assets include `other`/`total` metrics but not `action`/`view` -- Header format matches the W3C Server-Timing specification +- Header format matches the W3C Server Timing specification - Plugin behavior under different controller patterns (fast, slow, variable delay) - Multiple operations accumulate timing correctly diff --git a/AGENTS.md b/AGENTS.md index 3fe8654..a56a62d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -This is a **Grails Plugin** that injects `Server-Timing` HTTP headers into responses, implementing +This is a **Grails Plugin** that injects Server Timing HTTP headers into responses, implementing the [W3C Server Timing specification](https://w3c.github.io/server-timing/). It automatically tracks action time, view rendering time, and total request time, surfacing them in browser DevTools. diff --git a/README.md b/README.md index 648e15b..2c8fa11 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ [![Maven Central](https://img.shields.io/maven-central/v/org.grails.plugins/grails-server-timing)](https://central.sonatype.com/artifact/org.grails.plugins/grails-server-timing) [![License](https://img.shields.io/github/license/grails-plugins/grails-server-timing)](https://www.apache.org/licenses/LICENSE-2.0) -A Grails plugin that injects [ -`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing) HTTP headers into +A Grails plugin that injects [Server Timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing) HTTP headers into responses, implementing the [W3C Server Timing specification](https://w3c.github.io/server-timing/). It automatically tracks controller action time, view rendering time, and total request time -- surfacing them directly in your browser's DevTools. @@ -83,7 +82,7 @@ Server-Timing: total;dur=156.3;desc="Total", action;dur=45.2;desc="Action", view ## Viewing in Browser DevTools -Open DevTools (F12), go to the **Network** tab, click a request, and select the **Timing** tab. Server-Timing metrics +Open DevTools (F12), go to the **Network** tab, click a request, and select the **Timing** tab. Metrics appear under "Server Timing": - **Chrome** 65+ / **Edge** 79+ / **Opera** 52+ diff --git a/docs/src/docs/browser-tools.adoc b/docs/src/docs/browser-tools.adoc index 03f4b82..9873a75 100644 --- a/docs/src/docs/browser-tools.adoc +++ b/docs/src/docs/browser-tools.adoc @@ -1,8 +1,8 @@ == Using Browser Developer Tools -One of the primary benefits of the Server-Timing header is that modern browsers automatically display this information in their developer tools. +One of the primary benefits of the Server Timing header is that modern browsers automatically display this information in their developer tools. -=== Viewing Server-Timing in Chrome +=== Viewing Server Timing in Chrome 1. Open Chrome Developer Tools (F12 or Ctrl+Shift+I / Cmd+Option+I) 2. Navigate to the **Network** tab @@ -12,7 +12,7 @@ One of the primary benefits of the Server-Timing header is that modern browsers You'll see a breakdown of the request timing, including the custom metrics from this plugin displayed under "Server Timing". -=== Viewing Server-Timing in Firefox +=== Viewing Server Timing in Firefox 1. Open Firefox Developer Tools (F12 or Ctrl+Shift+I / Cmd+Option+I) 2. Navigate to the **Network** tab @@ -20,19 +20,19 @@ You'll see a breakdown of the request timing, including the custom metrics from 4. Click on the request in the list 5. Select the **Timings** tab in the details panel -The Server-Timing metrics appear at the bottom of the timing breakdown. +The Server Timing metrics appear at the bottom of the timing breakdown. -=== Viewing Server-Timing in Safari +=== Viewing Server Timing in Safari 1. Open Safari Web Inspector (Cmd+Option+I) 2. Navigate to the **Network** tab 3. Make a request to your Grails application 4. Click on the request in the list -5. Look for the Server-Timing section in the timing details +5. Look for the Server Timing section in the timing details === Understanding the Metrics -When viewing the Server-Timing data for a Grails controller request, you'll typically see: +When viewing the Server Timing data for a Grails controller request, you'll typically see: [cols="1,3"] |=== @@ -88,7 +88,7 @@ If `view` timing is high, investigate: === Programmatic Client Side Access -You can access Server-Timing data programmatically in JavaScript using the https://developer.mozilla.org/en-US/docs/Web/API/PerformanceServerTiming[Performance API^]: +You can access Server Timing data programmatically in JavaScript using the https://developer.mozilla.org/en-US/docs/Web/API/PerformanceServerTiming[Performance API^]: [source,javascript] ---- diff --git a/docs/src/docs/configuration.adoc b/docs/src/docs/configuration.adoc index 1a85916..d26e24c 100644 --- a/docs/src/docs/configuration.adoc +++ b/docs/src/docs/configuration.adoc @@ -78,7 +78,7 @@ environments: === Enabling in Production -WARNING: Enabling Server-Timing in production may expose timing information that could be useful to attackers. +WARNING: Enabling Server Timing in production may expose timing information that could be useful to attackers. Only enable in production if you understand the security implications and have appropriate access controls in place. If you need to enable timing headers in production (for example, behind an authenticated admin interface or internal network), you can do so: diff --git a/docs/src/docs/how-it-works.adoc b/docs/src/docs/how-it-works.adoc index e3d80d5..77f4501 100644 --- a/docs/src/docs/how-it-works.adoc +++ b/docs/src/docs/how-it-works.adoc @@ -42,7 +42,7 @@ When a request hits a Grails controller action, the following sequence occurs: - Stops 'action' timer if still running (edge case: action committed the response directly) - Stops 'view' timer if still running - Stops 'total' timer - - Adds Server-Timing header to response + - Adds Server Timing header to response ---- ==== Static Resources and Other Requests @@ -66,12 +66,12 @@ For requests that do not hit a Grails controller (static assets, images, CSS, Ja - ServerTimingResponseWrapper intercepts the commit - Stops 'other' timer - Stops 'total' timer - - Adds Server-Timing header to response + - Adds Server Timing header to response ---- === The Response Wrapper -A key technical challenge with Server-Timing is that HTTP headers must be sent *before* the response body. +A key technical challenge with Server Timing is that HTTP headers must be sent *before* the response body. However, we do not know the final timing values until *after* the view has rendered. The `ServerTimingResponseWrapper` solves this by: diff --git a/docs/src/docs/index.tmpl b/docs/src/docs/index.tmpl index 25b43a6..b6a9b99 100644 --- a/docs/src/docs/index.tmpl +++ b/docs/src/docs/index.tmpl @@ -259,7 +259,7 @@

Grails Server Timing

-

A Grails plugin for adding Server-Timing headers to your application

+

A Grails plugin for adding Server Timing headers to your application

diff --git a/docs/src/docs/introduction.adoc b/docs/src/docs/introduction.adoc index e8daa72..310289a 100644 --- a/docs/src/docs/introduction.adoc +++ b/docs/src/docs/introduction.adoc @@ -1,9 +1,9 @@ == Introduction -The Grails Server Timing plugin adds https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing[Server-Timing^] HTTP headers to your Grails application responses. +The Grails Server Timing plugin adds https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing[Server Timing^] HTTP headers to your Grails application responses. This allows you to see detailed server-side performance metrics directly in your browser's developer tools. -=== What is Server-Timing? +=== What is Server Timing? The `Server-Timing` header is part of the https://www.w3.org/TR/server-timing/[W3C Server Timing specification^] that enables servers to communicate performance metrics about the request-response cycle to the client. These metrics can include: @@ -28,7 +28,7 @@ All of this information is exposed via the standard `Server-Timing` header, whic === Browser Support -The Server-Timing header is supported by all modern browsers: +The Server Timing header is supported by all modern browsers: * Chrome 65+ * Firefox 61+ @@ -52,6 +52,6 @@ The plugin is automatically enabled in `development` and `test` environments onl No additional configuration is required to get started. NOTE: For security reasons, the plugin is **disabled by default in production**. -Server-Timing headers can expose timing information that may be useful to attackers. -See the <> and <> sections for more details. +Server Timing headers can expose timing information that may be useful to attackers. +See the <> and <> sections for more details. diff --git a/docs/src/docs/specification.adoc b/docs/src/docs/specification.adoc index 70f6736..0c6a197 100644 --- a/docs/src/docs/specification.adoc +++ b/docs/src/docs/specification.adoc @@ -1,11 +1,11 @@ -== Server-Timing Specification +== Server Timing Specification The `Server-Timing` header is a standardized way for servers to communicate performance metrics to clients. This section provides an overview of the specification and how this plugin implements it. === W3C Server Timing Specification -The Server-Timing header is defined by the https://www.w3.org/TR/server-timing/[W3C Server Timing specification^]. +The Server Timing header is defined by the https://www.w3.org/TR/server-timing/[W3C Server Timing specification^]. This specification is a W3C Working Draft that enables servers to communicate performance metrics about the request-response cycle. === Header Format @@ -52,7 +52,7 @@ Metrics without durations (used for presence indication) are not currently suppo === MDN Documentation -For detailed browser-side documentation, see the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing[MDN Server-Timing documentation^]. +For detailed browser-side documentation, see the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing[MDN Server Timing documentation^]. === Security Considerations @@ -73,7 +73,7 @@ See the <> section for details on how to change this behavior. ==== Cross-Origin Requests -By default, `Server-Timing` information is only available to same-origin requests. +By default, Server Timing information is only available to same-origin requests. For cross-origin requests, the server must include the `Timing-Allow-Origin` header: [source,http] diff --git a/examples/app1/grails-app/controllers/app1/ServerTimingTestController.groovy b/examples/app1/grails-app/controllers/app1/ServerTimingTestController.groovy index d27e4c9..d8aac45 100644 --- a/examples/app1/grails-app/controllers/app1/ServerTimingTestController.groovy +++ b/examples/app1/grails-app/controllers/app1/ServerTimingTestController.groovy @@ -3,7 +3,7 @@ package app1 import grails.converters.JSON /** - * A controller to test the Server-Timing HTTP header functionality. + * A controller to test the Server Timing HTTP header functionality. * Various actions simulate slow operations to verify timing is captured correctly. */ class ServerTimingTestController { @@ -90,7 +90,7 @@ class ServerTimingTestController { /** * An action that redirects to the fast action. - * This tests that the Server-Timing header is present on the redirect (302) response. + * This tests that the Server Timing header is present on the redirect (302) response. */ def redirectToFast() { Thread.sleep(50) @@ -99,7 +99,7 @@ class ServerTimingTestController { /** * An action that forwards to the forwardTarget action. - * This tests that the Server-Timing header is present when using server-side forward. + * This tests that the Server Timing header is present when using server-side forward. */ def forwardToTarget() { Thread.sleep(50) @@ -116,7 +116,7 @@ class ServerTimingTestController { /** * An action that chains to the chainTarget action, passing model data. - * This tests that the Server-Timing header is present when using Grails chain. + * This tests that the Server Timing header is present when using Grails chain. */ def chainToTarget() { Thread.sleep(50) diff --git a/examples/app1/grails-app/views/serverTimingTest/fastActionSlowView.gsp b/examples/app1/grails-app/views/serverTimingTest/fastActionSlowView.gsp index 0e4248c..e92fe4e 100644 --- a/examples/app1/grails-app/views/serverTimingTest/fastActionSlowView.gsp +++ b/examples/app1/grails-app/views/serverTimingTest/fastActionSlowView.gsp @@ -16,7 +16,7 @@ %>

View delay was: ${viewDelay ?: 150}ms

-

The Server-Timing header should show a fast action time and a slow view time.

+

The Server Timing header should show a fast action time and a slow view time.

diff --git a/examples/app1/grails-app/views/serverTimingTest/multipleOperations.gsp b/examples/app1/grails-app/views/serverTimingTest/multipleOperations.gsp index eb21d96..c3c3601 100644 --- a/examples/app1/grails-app/views/serverTimingTest/multipleOperations.gsp +++ b/examples/app1/grails-app/views/serverTimingTest/multipleOperations.gsp @@ -20,7 +20,7 @@

Total simulated delay: ${totalDelay}ms

-

The Server-Timing header should show the cumulative action time.

+

The Server Timing header should show the cumulative action time.

diff --git a/examples/app1/grails-app/views/serverTimingTest/slowAction.gsp b/examples/app1/grails-app/views/serverTimingTest/slowAction.gsp index 640aeac..e5f1d1d 100644 --- a/examples/app1/grails-app/views/serverTimingTest/slowAction.gsp +++ b/examples/app1/grails-app/views/serverTimingTest/slowAction.gsp @@ -11,7 +11,7 @@

${message}

-

The Server-Timing header should show the action time being significantly longer than the view time.

+

The Server Timing header should show the action time being significantly longer than the view time.

This action took approximately 200ms to execute.

diff --git a/examples/app1/grails-app/views/serverTimingTest/slowActionSlowView.gsp b/examples/app1/grails-app/views/serverTimingTest/slowActionSlowView.gsp index acb910e..c79f848 100644 --- a/examples/app1/grails-app/views/serverTimingTest/slowActionSlowView.gsp +++ b/examples/app1/grails-app/views/serverTimingTest/slowActionSlowView.gsp @@ -20,7 +20,7 @@

View delay was: ${viewDelay ?: 100}ms

-

The Server-Timing header should show significant time for both action and view.

+

The Server Timing header should show significant time for both action and view.

diff --git a/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy b/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy index d97eee4..1f30a6a 100644 --- a/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy +++ b/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy @@ -8,7 +8,7 @@ import spock.lang.Shared import spock.lang.Specification /** - * Integration tests for the Server-Timing HTTP header functionality. + * Integration tests for the Server Timing HTTP header functionality. * Tests verify that the plugin correctly adds timing information * for controller actions and view rendering. */ @@ -26,11 +26,11 @@ class ServerTimingIntegrationSpec extends Specification { restTemplate.exchange("${baseUrl}${path}", HttpMethod.GET, null, String) } - void "fast action should include Server-Timing header"() { + void "fast action should include Server Timing header"() { when: 'we request the fast action' ResponseEntity response = doGet('/serverTimingTest/fast') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' response.headers.getFirst('Server-Timing') != null and: 'the header should contain action and view metrics' @@ -43,7 +43,7 @@ class ServerTimingIntegrationSpec extends Specification { when: 'we request the slow action' ResponseEntity response = doGet('/serverTimingTest/slowAction') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -59,7 +59,7 @@ class ServerTimingIntegrationSpec extends Specification { when: 'we request the variable delay action' ResponseEntity response = doGet("/serverTimingTest/variableDelay?delay=${requestedDelay}") - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -72,7 +72,7 @@ class ServerTimingIntegrationSpec extends Specification { when: 'we request the fast action with slow view' ResponseEntity response = doGet('/serverTimingTest/fastActionSlowView?viewDelay=150') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -89,7 +89,7 @@ class ServerTimingIntegrationSpec extends Specification { when: 'we request the slow action with slow view' ResponseEntity response = doGet('/serverTimingTest/slowActionSlowView?viewDelay=100') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -106,7 +106,7 @@ class ServerTimingIntegrationSpec extends Specification { when: 'we request the multiple operations action' ResponseEntity response = doGet('/serverTimingTest/multipleOperations') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -115,11 +115,11 @@ class ServerTimingIntegrationSpec extends Specification { actionDur >= 150.0 } - void "JSON response should include Server-Timing header"() { + void "JSON response should include Server Timing header"() { when: 'we request the JSON action' ResponseEntity response = doGet('/serverTimingTest/jsonResponse') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -128,11 +128,11 @@ class ServerTimingIntegrationSpec extends Specification { actionDur >= 50.0 } - void "text response should include Server-Timing header"() { + void "text response should include Server Timing header"() { when: 'we request the text action' ResponseEntity response = doGet('/serverTimingTest/textResponse') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -141,11 +141,11 @@ class ServerTimingIntegrationSpec extends Specification { actionDur >= 30.0 } - void "Server-Timing header format should be correct"() { + void "Server Timing header format should be correct"() { when: 'we request any action' ResponseEntity response = doGet('/serverTimingTest/fast') - then: 'the Server-Timing header should follow the spec format' + then: 'the Server Timing header should follow the spec format' String serverTiming = response.headers.getFirst('Server-Timing') // Header should contain metric name, duration, and description @@ -154,20 +154,20 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming =~ /view;dur=[\d.]+;desc="[^"]+"/ } - void "index page should include Server-Timing header"() { + void "index page should include Server Timing header"() { when: 'we request the index page' ResponseEntity response = doGet('/') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null } - void "static asset should include Server-Timing header with other metric"() { + void "static asset should include Server Timing header with other metric"() { when: 'we request a static asset' ResponseEntity response = doGet('/assets/application.css?compile=false') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -178,11 +178,11 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming.contains('total') } - void "redirect response should include Server-Timing header"() { + void "redirect response should include Server Timing header"() { when: 'we request an action that redirects' ResponseEntity response = doGet('/serverTimingTest/redirectToFast') - then: 'the final response (after following redirect) should have a Server-Timing header' + then: 'the final response (after following redirect) should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -191,11 +191,11 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming.contains('view') } - void "redirect response should include Server-Timing header with timing >= 50ms"() { + void "redirect response should include Server Timing header with timing >= 50ms"() { when: 'we request an action that sleeps 50ms then redirects' ResponseEntity response = doGet('/serverTimingTest/redirectToFast') - then: 'the final response should have a Server-Timing header with total time' + then: 'the final response should have a Server Timing header with total time' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -203,11 +203,11 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming.contains('total') } - void "forward should include Server-Timing header"() { + void "forward should include Server Timing header"() { when: 'we request an action that forwards to another action' ResponseEntity response = doGet('/serverTimingTest/forwardToTarget') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -218,11 +218,11 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming.contains('total') } - void "forward should include Server-Timing header with view metric"() { + void "forward should include Server Timing header with view metric"() { when: 'we request an action that forwards to another action with a view' ResponseEntity response = doGet('/serverTimingTest/forwardToTarget') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -230,11 +230,11 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming.contains('view') } - void "chain should include Server-Timing header"() { + void "chain should include Server Timing header"() { when: 'we request an action that chains to another action' ResponseEntity response = doGet('/serverTimingTest/chainToTarget') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -245,11 +245,11 @@ class ServerTimingIntegrationSpec extends Specification { serverTiming.contains('total') } - void "chain should include Server-Timing header with view metric"() { + void "chain should include Server Timing header with view metric"() { when: 'we request an action that chains to another action with a view' ResponseEntity response = doGet('/serverTimingTest/chainToTarget') - then: 'the response should have a Server-Timing header' + then: 'the response should have a Server Timing header' String serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null @@ -258,8 +258,8 @@ class ServerTimingIntegrationSpec extends Specification { } /** - * Extracts the duration value for a given metric name from the Server-Timing header. - * @param serverTimingHeader The full Server-Timing header value + * Extracts the duration value for a given metric name from the Server Timing header. + * @param serverTimingHeader The full Server Timing header value * @param metricName The name of the metric to extract * @return The duration value in milliseconds, or null if not found */ diff --git a/examples/app2/grails-app/controllers/app2/ServerTimingDisabledTestController.groovy b/examples/app2/grails-app/controllers/app2/ServerTimingDisabledTestController.groovy index 071dbd1..c2b26ca 100644 --- a/examples/app2/grails-app/controllers/app2/ServerTimingDisabledTestController.groovy +++ b/examples/app2/grails-app/controllers/app2/ServerTimingDisabledTestController.groovy @@ -3,7 +3,7 @@ package app2 import grails.converters.JSON /** - * A controller to test that the Server-Timing HTTP header is NOT present + * A controller to test that the Server Timing HTTP header is NOT present * when the plugin is explicitly disabled via configuration. */ class ServerTimingDisabledTestController { diff --git a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy index b1f2245..014724d 100644 --- a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy +++ b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy @@ -8,7 +8,7 @@ import spock.lang.Shared import spock.lang.Specification /** - * Integration tests verifying that the Server-Timing HTTP header is NOT present + * Integration tests verifying that the Server Timing HTTP header is NOT present * when the plugin is explicitly disabled via configuration * (grails.plugins.servertiming.enabled: false). */ @@ -26,51 +26,51 @@ class ServerTimingDisabledIntegrationSpec extends Specification { restTemplate.exchange("${baseUrl}${path}", HttpMethod.GET, null, String) } - void "fast action should NOT include Server-Timing header when plugin is disabled"() { + void "fast action should NOT include Server Timing header when plugin is disabled"() { when: 'we request the fast action' ResponseEntity response = doGet('/serverTimingDisabledTest/fast') - then: 'the response should NOT have a Server-Timing header' + then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null } - void "slow action should NOT include Server-Timing header when plugin is disabled"() { + void "slow action should NOT include Server Timing header when plugin is disabled"() { when: 'we request the slow action' ResponseEntity response = doGet('/serverTimingDisabledTest/slowAction') - then: 'the response should NOT have a Server-Timing header' + then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null } - void "JSON response should NOT include Server-Timing header when plugin is disabled"() { + void "JSON response should NOT include Server Timing header when plugin is disabled"() { when: 'we request the JSON action' ResponseEntity response = doGet('/serverTimingDisabledTest/jsonResponse') - then: 'the response should NOT have a Server-Timing header' + then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null } - void "text response should NOT include Server-Timing header when plugin is disabled"() { + void "text response should NOT include Server Timing header when plugin is disabled"() { when: 'we request the text action' ResponseEntity response = doGet('/serverTimingDisabledTest/textResponse') - then: 'the response should NOT have a Server-Timing header' + then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null } - void "index page should NOT include Server-Timing header when plugin is disabled"() { + void "index page should NOT include Server Timing header when plugin is disabled"() { when: 'we request the index page' ResponseEntity response = doGet('/') - then: 'the response should NOT have a Server-Timing header' + then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null } - void "static asset should NOT include Server-Timing header when plugin is disabled"() { + void "static asset should NOT include Server Timing header when plugin is disabled"() { when: 'we request a static asset' ResponseEntity response = doGet('/assets/application.css?compile=false') - then: 'the response should NOT have a Server-Timing header' + then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null } } diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy index 8e08941..b4da9a8 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy @@ -6,10 +6,10 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.core.Ordered /** - * Grails plugin that provides Server-Timing header support for HTTP responses. + * Grails plugin that provides Server Timing header support for HTTP responses. * *

This plugin automatically registers a {@link ServerTimingFilter} that adds - * Server-Timing headers to HTTP responses, + * Server Timing headers to HTTP responses, * allowing developers to communicate backend server performance metrics to the browser.

* *

Configuration

@@ -40,7 +40,7 @@ class GrailsServerTimingGrailsPlugin extends Plugin { def author = 'James Daugherty' /** Plugin description */ - def description = 'A Grails plugin to generate Server-Timing headers for HTTP responses.' + def description = 'A Grails plugin to generate Server Timing headers for HTTP responses.' /** URL to the plugin documentation */ def documentation = 'https://grails-plugins.github.io/grails-server-timing/' @@ -52,7 +52,7 @@ class GrailsServerTimingGrailsPlugin extends Plugin { def scm = [url: 'https://github.com/grails-plugins/grails-server-timing'] /** - * Registers Spring beans for the Server-Timing functionality. + * Registers Spring beans for the Server Timing functionality. * *

When the plugin is enabled, this method registers:

*
    diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index 945c909..8b17236 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy @@ -14,11 +14,11 @@ import org.grails.plugins.servertiming.core.TimingMetric import org.springframework.core.Ordered /** - * A Servlet Filter that wraps responses to ensure Server-Timing headers are added to HTTP responses. + * A Servlet Filter that wraps responses to ensure Server Timing headers are added to HTTP responses. * * This filter works in conjunction with the TimingMetricInterceptor & ServerTimingResponseWrapper. * The interceptor assists in creating initial timing metrics for actions & views - * The response wrapper ensures the Server-Timing header is added before the response is committed. + * The response wrapper ensures the Server Timing header is added before the response is committed. * For non-controller requests (static resources, etc.), the filter tracks timing as 'other'. */ @Slf4j diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy index 4181b43..a5f31af 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy @@ -10,7 +10,7 @@ import org.grails.plugins.servertiming.core.Metric import org.grails.plugins.servertiming.core.TimingMetric /** - * A response wrapper that intercepts the response commit to add the Server-Timing header + * A response wrapper that intercepts the response commit to add the Server Timing header * before the response is actually committed. */ @Slf4j @@ -30,7 +30,7 @@ class ServerTimingResponseWrapper extends HttpServletResponseWrapper { } /** - * Adds the Server-Timing header if not already added. + * Adds the Server Timing header if not already added. */ private void addServerTimingHeaderIfNeeded() { if (!headerAdded && timing) { diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy index 29a9873..d985927 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy @@ -6,7 +6,7 @@ import grails.validation.Validateable import java.time.Duration /** - * Implements a metric for the Server-Timing header + * Implements a metric for the Server Timing header * * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing */ diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy index 7508b4c..45f9987 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy @@ -4,7 +4,7 @@ import grails.validation.ValidationException import groovy.transform.CompileStatic /** - * Implements a collection of metrics for the Server-Timing header + * Implements a collection of metrics for the Server Timing header * * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing */ diff --git a/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy b/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy index c189a12..7d60ee3 100644 --- a/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy +++ b/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy @@ -349,7 +349,7 @@ class MetricSpec extends Specification { metric.duration != null } - // Server-Timing spec compliance tests + // Server Timing spec compliance tests // See: https://w3c.github.io/server-timing/#the-server-timing-header-field def "test toHeaderValue() duration is in milliseconds with decimal precision"() { @@ -367,7 +367,7 @@ class MetricSpec extends Specification { header ==~ /db;dur=\d+\.\d/ } - def "test toHeaderValue() format matches Server-Timing spec"() { + def "test toHeaderValue() format matches Server Timing spec"() { given: Metric metric = new Metric(name: 'cache', description: 'Cache Read') metric.start() @@ -447,7 +447,7 @@ class MetricSpec extends Specification { } def "test header value with only name is valid per spec"() { - // Server-Timing allows metrics with just a name (no dur or desc) + // Server Timing allows metrics with just a name (no dur or desc) given: Metric metric = new Metric(name: 'miss') diff --git a/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy b/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy index c097ac5..6430d41 100644 --- a/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy +++ b/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy @@ -269,7 +269,7 @@ class TimingMetricSpec extends Specification { deserialized.get('cache').name == 'cache' } - def "test toHeaderValue() format matches Server-Timing spec"() { + def "test toHeaderValue() format matches Server Timing spec"() { // Server-Timing header format: metric-name;dur=value;desc="description", ... given: TimingMetric timingMetric = new TimingMetric() From 38097a6fcc1910ba8f9b8908b644c527528187f7 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:05:44 -0500 Subject: [PATCH 02/47] feedback: servertiming -> serverTiming for config key --- AGENTS.md | 4 ++-- README.md | 4 ++-- docs/src/docs/configuration.adoc | 14 +++++++------- examples/app2/grails-app/conf/application.yml | 2 +- .../ServerTimingDisabledIntegrationSpec.groovy | 2 +- .../servertiming/ServerTimingInterceptor.groovy | 4 ++-- .../GrailsServerTimingGrailsPlugin.groovy | 4 ++-- .../plugins/servertiming/ServerTimingUtils.groovy | 4 ++-- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a56a62d..fb1a60a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,8 +132,8 @@ Set in `application.yml`: | Property | Default | Description | |-----------------------------------------|--------------------------------------------|-------------------------------------------| -| `grails.plugins.servertiming.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | -| `grails.plugins.servertiming.metricKey` | `GrailsServerTiming` | Request attribute key for storing metrics | +| `grails.plugins.serverTiming.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | +| `grails.plugins.serverTiming.metricKey` | `GrailsServerTiming` | Request attribute key for storing metrics | **Security note:** The plugin is disabled in production by default because timing data could facilitate timing attacks. diff --git a/README.md b/README.md index 2c8fa11..7bff92b 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,12 @@ environments: development: grails: plugins: - servertiming: + serverTiming: enabled: true production: grails: plugins: - servertiming: + serverTiming: enabled: false ``` diff --git a/docs/src/docs/configuration.adoc b/docs/src/docs/configuration.adoc index d26e24c..a124e87 100644 --- a/docs/src/docs/configuration.adoc +++ b/docs/src/docs/configuration.adoc @@ -17,14 +17,14 @@ All configuration options are specified in your `application.yml` or `applicatio ---- grails: plugins: - servertiming: + serverTiming: enabled: true # or false ---- |=== | Property | Type | Default | Description -| `grails.plugins.servertiming.enabled` +| `grails.plugins.serverTiming.enabled` | `Boolean` | `null` (auto-detect) | When `null`, the plugin is enabled in `development` and `test` environments only. Set to `true` to explicitly enable or `false` to explicitly disable regardless of environment. @@ -46,7 +46,7 @@ grails: |=== | Property | Type | Default | Description -| `grails.plugins.servertiming.metricKey` +| `grails.plugins.serverTiming.metricKey` | `String` | `GrailsServerTiming` | The request attribute key used to store the `TimingMetric` object. Only change this if you have a naming conflict. @@ -62,17 +62,17 @@ environments: development: grails: plugins: - servertiming: + serverTiming: enabled: true test: grails: plugins: - servertiming: + serverTiming: enabled: true production: grails: plugins: - servertiming: + serverTiming: enabled: false ---- @@ -89,7 +89,7 @@ environments: production: grails: plugins: - servertiming: + serverTiming: enabled: true ---- diff --git a/examples/app2/grails-app/conf/application.yml b/examples/app2/grails-app/conf/application.yml index 119a5b0..37a771a 100644 --- a/examples/app2/grails-app/conf/application.yml +++ b/examples/app2/grails-app/conf/application.yml @@ -9,7 +9,7 @@ grails: # Disabled by default for performance reasons events: false plugins: - servertiming: + serverTiming: enabled: false info: app: diff --git a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy index 014724d..c960edd 100644 --- a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy +++ b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy @@ -10,7 +10,7 @@ import spock.lang.Specification /** * Integration tests verifying that the Server Timing HTTP header is NOT present * when the plugin is explicitly disabled via configuration - * (grails.plugins.servertiming.enabled: false). + * (grails.plugins.serverTiming.enabled: false). */ @Integration class ServerTimingDisabledIntegrationSpec extends Specification { diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index ffd2752..600625b 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -19,10 +19,10 @@ class ServerTimingInterceptor implements Interceptor { ServerTimingInterceptor() { if (ServerTimingUtils.instance.enabled) { - log.debug("Server Timing metrics are enabled. Set 'grails.plugins.servertiming.enabled' to false to disable them.") + log.debug("Server Timing metrics are enabled. Set 'grails.plugins.serverTiming.enabled' to false to disable them.") matchAll() } else { - log.debug("Server Timing metrics are disabled. Set 'grails.plugins.servertiming.enabled' to true to enable them.") + log.debug("Server Timing metrics are disabled. Set 'grails.plugins.serverTiming.enabled' to true to enable them.") } } diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy index b4da9a8..8318c0b 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy @@ -15,7 +15,7 @@ import org.springframework.core.Ordered *

    Configuration

    *

    The plugin can be enabled or disabled via the configuration property:

    *
    - * grails.plugins.servertiming.enabled = true
    + * grails.plugins.serverTiming.enabled = true
      * 
    * *

    Filter Registration

    @@ -88,7 +88,7 @@ class GrailsServerTimingGrailsPlugin extends Plugin { if (ServerTimingUtils.instance.enabled) { log.debug('Applying {} plugin', title) } else { - log.debug('{} plugin is disabled. Set \'grails.plugins.servertiming.enabled\' to true to enable it.', title) + log.debug('{} plugin is disabled. Set \'grails.plugins.serverTiming.enabled\' to true to enable it.', title) } } } diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy index 9d97b2e..8126af7 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy @@ -12,7 +12,7 @@ import groovy.transform.CompileStatic class ServerTimingUtils { boolean isEnabled() { - Boolean explicitlyEnabled = Holders.config.getProperty('grails.plugins.servertiming.enabled', Boolean, null) + Boolean explicitlyEnabled = Holders.config.getProperty('grails.plugins.serverTiming.enabled', Boolean, null) if (explicitlyEnabled != null) { return explicitlyEnabled } @@ -21,6 +21,6 @@ class ServerTimingUtils { } String getMetricKey() { - Holders.config.getProperty('grails.plugins.servertiming.metricKey', String, 'GrailsServerTiming') + Holders.config.getProperty('grails.plugins.serverTiming.metricKey', String, 'GrailsServerTiming') } } From 85f93a21ddc7f19b4a6435b98a1e382a6ba9720c Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:06:22 -0500 Subject: [PATCH 03/47] feedback: Remove unnecessary Application class in plugin --- .../plugins/servertiming/Application.groovy | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy diff --git a/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy b/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy deleted file mode 100644 index b8f0dff..0000000 --- a/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy +++ /dev/null @@ -1,15 +0,0 @@ -package org.grails.plugins.servertiming - -import grails.boot.GrailsApp -import grails.boot.config.GrailsAutoConfiguration -import grails.plugins.metadata.PluginSource -import groovy.transform.CompileStatic - -@PluginSource -@CompileStatic -class Application extends GrailsAutoConfiguration { - - static void main(String[] args) { - GrailsApp.run(Application, args) - } -} From 9ab357975a4558993f56d60ef584be3a1a03d8fe Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:08:03 -0500 Subject: [PATCH 04/47] Revert "feedback: Remove unnecessary Application class in plugin" This reverts commit 85f93a21ddc7f19b4a6435b98a1e382a6ba9720c. --- .../plugins/servertiming/Application.groovy | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy diff --git a/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy b/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy new file mode 100644 index 0000000..b8f0dff --- /dev/null +++ b/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy @@ -0,0 +1,15 @@ +package org.grails.plugins.servertiming + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.PluginSource +import groovy.transform.CompileStatic + +@PluginSource +@CompileStatic +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} From ee15067754aba701750773d99c09beffe41a1e9d Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:08:55 -0500 Subject: [PATCH 05/47] feedback: simplify plugin configuration --- plugin/grails-app/conf/application.yml | 72 -------------------------- 1 file changed, 72 deletions(-) diff --git a/plugin/grails-app/conf/application.yml b/plugin/grails-app/conf/application.yml index 6e58b7d..1b73c8e 100644 --- a/plugin/grails-app/conf/application.yml +++ b/plugin/grails-app/conf/application.yml @@ -1,76 +1,4 @@ -info: - app: - name: '@info.app.name@' - version: '@info.app.version@' - grailsVersion: '@info.app.grailsVersion@' grails: - views: - default: - codec: html - gsp: - encoding: UTF-8 - htmlcodec: xml - codecs: - expression: html - scriptlet: html - taglib: none - staticparts: none - mime: - disable: - accept: - header: - userAgents: - - Gecko - - WebKit - - Presto - - Trident - types: - all: '*/*' - atom: application/atom+xml - css: text/css - csv: text/csv - form: application/x-www-form-urlencoded - html: - - text/html - - application/xhtml+xml - js: text/javascript - json: - - application/json - - text/json - multipartForm: multipart/form-data - pdf: application/pdf - rss: application/rss+xml - text: text/plain - hal: - - application/hal+json - - application/hal+xml - xml: - - text/xml - - application/xml codegen: defaultPackage: org.grails.plugins.servertiming profile: web-plugin -dataSource: - driverClassName: org.h2.Driver - username: sa - password: '' - pooled: true - jmxExport: true -environments: - development: - dataSource: - dbCreate: create-drop - url: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE - test: - dataSource: - dbCreate: update - url: jdbc:h2:mem:testDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE - production: - dataSource: - dbCreate: none - url: jdbc:h2:./prodDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE -hibernate: - cache: - queries: false - use_second_level_cache: false - use_query_cache: false From 6cf6dde251a31ccb40282f88cbc03d4e8ee066da Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:09:47 -0500 Subject: [PATCH 06/47] feedback: move log configuration to test configuration only --- plugin/{grails-app/conf => src/test/resources}/logback-spring.xml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugin/{grails-app/conf => src/test/resources}/logback-spring.xml (100%) diff --git a/plugin/grails-app/conf/logback-spring.xml b/plugin/src/test/resources/logback-spring.xml similarity index 100% rename from plugin/grails-app/conf/logback-spring.xml rename to plugin/src/test/resources/logback-spring.xml From d0d3055b96609bca2d43a4e7a97f8fc8fff0e9e8 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:10:37 -0500 Subject: [PATCH 07/47] feedback: switch to CompileStatic when GrailsCompileStatic is not needed --- .../plugins/servertiming/ServerTimingInterceptor.groovy | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index 600625b..1c4a991 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -1,7 +1,8 @@ package org.grails.plugins.servertiming +import groovy.transform.CompileStatic + import grails.artefact.Interceptor -import grails.compiler.GrailsCompileStatic import groovy.util.logging.Slf4j import org.grails.plugins.servertiming.core.TimingMetric @@ -10,7 +11,7 @@ import org.grails.plugins.servertiming.core.TimingMetric * Works in conjunction with ServerTimingFilter which handles adding the HTTP header. */ @Slf4j -@GrailsCompileStatic +@CompileStatic class ServerTimingInterceptor implements Interceptor { static String HEADER_NAME = 'Server-Timing' From 1d283e51af2523639dde04c7aea8a3625efa82ec Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:11:07 -0500 Subject: [PATCH 08/47] feedback: rename plugin class --- ...imingGrailsPlugin.groovy => ServerTimingGrailsPlugin.groovy} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename plugin/src/main/groovy/org/grails/plugins/servertiming/{GrailsServerTimingGrailsPlugin.groovy => ServerTimingGrailsPlugin.groovy} (98%) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy similarity index 98% rename from plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy rename to plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 8318c0b..86d41cf 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -28,7 +28,7 @@ import org.springframework.core.Ordered * @see ServerTimingFilter* @see ServerTimingUtils */ @Slf4j -class GrailsServerTimingGrailsPlugin extends Plugin { +class ServerTimingGrailsPlugin extends Plugin { /** Minimum Grails version required for this plugin */ def grailsVersion = '7.0.7 > *' From 2a00b3bf4c6516107a742232429323150b1b2f83 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:12:13 -0500 Subject: [PATCH 09/47] feedback - 7.0.7 -> 7.x --- AGENTS.md | 2 +- .../grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb1a60a..07ab479 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ the [W3C Server Timing specification](https://w3c.github.io/server-timing/). It rendering time, and total request time, surfacing them in browser DevTools. - **Language:** Groovy 4.0.30 on Java 17 -- **Framework:** Grails 7.0.7 +- **Framework:** Grails 7.x - **Build System:** Gradle 8.14.4 (with wrapper) - **Current Version:** 0.0.1-SNAPSHOT - **License:** Apache 2.0 diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 86d41cf..9e46e29 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -31,7 +31,7 @@ import org.springframework.core.Ordered class ServerTimingGrailsPlugin extends Plugin { /** Minimum Grails version required for this plugin */ - def grailsVersion = '7.0.7 > *' + def grailsVersion = '7.0.0 > *' /** Plugin title */ def title = 'grails-server-timing' From 0e1b0b6f7b0de1d69fd82b03c2101ad4acfa1ca3 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:13:32 -0500 Subject: [PATCH 10/47] feedback: rename title --- .../grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 9e46e29..13c1e68 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -34,7 +34,7 @@ class ServerTimingGrailsPlugin extends Plugin { def grailsVersion = '7.0.0 > *' /** Plugin title */ - def title = 'grails-server-timing' + def title = 'Server Timing' /** Plugin author */ def author = 'James Daugherty' From 9fde9128108176793d94d41be62e2e2b03b7f98e Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:14:24 -0500 Subject: [PATCH 11/47] feedback: lazy log messages --- .../org/grails/plugins/servertiming/ServerTimingFilter.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index 8b17236..6546912 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy @@ -37,7 +37,7 @@ class ServerTimingFilter implements Filter, Ordered { throws IOException, ServletException { if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { - log.warn("Could not apply Server Timing Filter because request or response was not an expected HttpServlet type: ${request.getClass()} / ${response.getClass()}") + log.warn("Could not apply Server Timing Filter because request or response was not an expected HttpServlet type: {} / {}", request.class, response.class) chain.doFilter(request, response) return } From 39effda1c1854bce83970467da5790144a357f2c Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 12:28:55 -0500 Subject: [PATCH 12/47] feedback: use def for local variables where type is inferred --- .skills/example-apps.md | 4 +- AGENTS.md | 3 + .../app1/ServerTimingIntegrationSpec.groovy | 69 +++++---- ...ServerTimingDisabledIntegrationSpec.groovy | 12 +- .../ServerTimingInterceptor.groovy | 4 +- .../servertiming/ServerTimingFilter.groovy | 10 +- .../ServerTimingResponseWrapper.groovy | 7 +- .../plugins/servertiming/core/Metric.groovy | 14 +- .../servertiming/core/TimingMetric.groovy | 2 +- .../plugins/servertiming/MetricSpec.groovy | 144 +++++++++--------- .../servertiming/TimingMetricSpec.groovy | 98 ++++++------ 11 files changed, 184 insertions(+), 183 deletions(-) diff --git a/.skills/example-apps.md b/.skills/example-apps.md index 5685594..2a92dbb 100644 --- a/.skills/example-apps.md +++ b/.skills/example-apps.md @@ -145,11 +145,11 @@ class ServerTimingIntegrationSpec extends Specification { void "fast action should include Server Timing header"() { when: - ResponseEntity response = doGet('/serverTimingTest/fast') + def response = doGet('/serverTimingTest/fast') then: response.headers.getFirst('Server-Timing') != null - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming.contains('action') serverTiming.contains('view') } diff --git a/AGENTS.md b/AGENTS.md index 07ab479..afe3ad9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,9 @@ Convention plugins in `build-logic/src/main/groovy/` standardize build configura - Groovy source files use standard Grails conventions (domain classes, controllers, interceptors, services in `grails-app/`, other classes in `src/main/groovy/`). +- **Use `def` for local variables** where the type is inferred from the right-hand side (e.g., constructor calls, casts, + factory methods). Explicit types should only be used for local variables when the type cannot be inferred or when + needed for `@CompileStatic` compilation. This applies to both production code and tests. - Metric names must conform to RFC 7230 token rules (alphanumeric plus `!#$%&'*+-.^_`|~`). - Description strings follow HTTP quoted-string escaping rules. - The plugin uses `System.nanoTime()` for timing precision. diff --git a/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy b/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy index 1f30a6a..fd614e3 100644 --- a/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy +++ b/examples/app1/src/integration-test/groovy/app1/ServerTimingIntegrationSpec.groovy @@ -28,23 +28,23 @@ class ServerTimingIntegrationSpec extends Specification { void "fast action should include Server Timing header"() { when: 'we request the fast action' - ResponseEntity response = doGet('/serverTimingTest/fast') + def response = doGet('/serverTimingTest/fast') then: 'the response should have a Server Timing header' response.headers.getFirst('Server-Timing') != null and: 'the header should contain action and view metrics' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming.contains('action') serverTiming.contains('view') } void "slow action (200ms) should show action timing >= 200ms"() { when: 'we request the slow action' - ResponseEntity response = doGet('/serverTimingTest/slowAction') + def response = doGet('/serverTimingTest/slowAction') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 200ms' @@ -57,10 +57,10 @@ class ServerTimingIntegrationSpec extends Specification { int requestedDelay = 150 when: 'we request the variable delay action' - ResponseEntity response = doGet("/serverTimingTest/variableDelay?delay=${requestedDelay}") + def response = doGet("/serverTimingTest/variableDelay?delay=${requestedDelay}") then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least the requested delay' @@ -70,10 +70,10 @@ class ServerTimingIntegrationSpec extends Specification { void "fast action with slow view should show view timing >= 150ms"() { when: 'we request the fast action with slow view' - ResponseEntity response = doGet('/serverTimingTest/fastActionSlowView?viewDelay=150') + def response = doGet('/serverTimingTest/fastActionSlowView?viewDelay=150') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the view timing should be at least 150ms' @@ -87,10 +87,10 @@ class ServerTimingIntegrationSpec extends Specification { void "slow action slow view should show both timings being significant"() { when: 'we request the slow action with slow view' - ResponseEntity response = doGet('/serverTimingTest/slowActionSlowView?viewDelay=100') + def response = doGet('/serverTimingTest/slowActionSlowView?viewDelay=100') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 100ms' @@ -104,10 +104,10 @@ class ServerTimingIntegrationSpec extends Specification { void "multiple operations should accumulate in action timing"() { when: 'we request the multiple operations action' - ResponseEntity response = doGet('/serverTimingTest/multipleOperations') + def response = doGet('/serverTimingTest/multipleOperations') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 150ms (sum of 50+75+25)' @@ -117,10 +117,10 @@ class ServerTimingIntegrationSpec extends Specification { void "JSON response should include Server Timing header"() { when: 'we request the JSON action' - ResponseEntity response = doGet('/serverTimingTest/jsonResponse') + def response = doGet('/serverTimingTest/jsonResponse') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 50ms' @@ -130,10 +130,10 @@ class ServerTimingIntegrationSpec extends Specification { void "text response should include Server Timing header"() { when: 'we request the text action' - ResponseEntity response = doGet('/serverTimingTest/textResponse') + def response = doGet('/serverTimingTest/textResponse') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 30ms' @@ -143,10 +143,10 @@ class ServerTimingIntegrationSpec extends Specification { void "Server Timing header format should be correct"() { when: 'we request any action' - ResponseEntity response = doGet('/serverTimingTest/fast') + def response = doGet('/serverTimingTest/fast') then: 'the Server Timing header should follow the spec format' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') // Header should contain metric name, duration, and description // Format: name;dur=X;desc="description" @@ -156,19 +156,19 @@ class ServerTimingIntegrationSpec extends Specification { void "index page should include Server Timing header"() { when: 'we request the index page' - ResponseEntity response = doGet('/') + def response = doGet('/') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null } void "static asset should include Server Timing header with other metric"() { when: 'we request a static asset' - ResponseEntity response = doGet('/assets/application.css?compile=false') + def response = doGet('/assets/application.css?compile=false') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: "the header should contain 'other' metric (not action/view)" @@ -180,10 +180,10 @@ class ServerTimingIntegrationSpec extends Specification { void "redirect response should include Server Timing header"() { when: 'we request an action that redirects' - ResponseEntity response = doGet('/serverTimingTest/redirectToFast') + def response = doGet('/serverTimingTest/redirectToFast') then: 'the final response (after following redirect) should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain action and view metrics from the target action' @@ -193,10 +193,10 @@ class ServerTimingIntegrationSpec extends Specification { void "redirect response should include Server Timing header with timing >= 50ms"() { when: 'we request an action that sleeps 50ms then redirects' - ResponseEntity response = doGet('/serverTimingTest/redirectToFast') + def response = doGet('/serverTimingTest/redirectToFast') then: 'the final response should have a Server Timing header with total time' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'total should be present' @@ -205,10 +205,10 @@ class ServerTimingIntegrationSpec extends Specification { void "forward should include Server Timing header"() { when: 'we request an action that forwards to another action' - ResponseEntity response = doGet('/serverTimingTest/forwardToTarget') + def response = doGet('/serverTimingTest/forwardToTarget') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain action metrics' @@ -220,10 +220,10 @@ class ServerTimingIntegrationSpec extends Specification { void "forward should include Server Timing header with view metric"() { when: 'we request an action that forwards to another action with a view' - ResponseEntity response = doGet('/serverTimingTest/forwardToTarget') + def response = doGet('/serverTimingTest/forwardToTarget') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain view metric since the target action renders a view' @@ -232,10 +232,10 @@ class ServerTimingIntegrationSpec extends Specification { void "chain should include Server Timing header"() { when: 'we request an action that chains to another action' - ResponseEntity response = doGet('/serverTimingTest/chainToTarget') + def response = doGet('/serverTimingTest/chainToTarget') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain action metrics' @@ -247,10 +247,10 @@ class ServerTimingIntegrationSpec extends Specification { void "chain should include Server Timing header with view metric"() { when: 'we request an action that chains to another action with a view' - ResponseEntity response = doGet('/serverTimingTest/chainToTarget') + def response = doGet('/serverTimingTest/chainToTarget') then: 'the response should have a Server Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain view metric since the chain target renders a view' @@ -273,4 +273,3 @@ class ServerTimingIntegrationSpec extends Specification { return null } } - diff --git a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy index c960edd..0035ef6 100644 --- a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy +++ b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy @@ -28,7 +28,7 @@ class ServerTimingDisabledIntegrationSpec extends Specification { void "fast action should NOT include Server Timing header when plugin is disabled"() { when: 'we request the fast action' - ResponseEntity response = doGet('/serverTimingDisabledTest/fast') + def response = doGet('/serverTimingDisabledTest/fast') then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null @@ -36,7 +36,7 @@ class ServerTimingDisabledIntegrationSpec extends Specification { void "slow action should NOT include Server Timing header when plugin is disabled"() { when: 'we request the slow action' - ResponseEntity response = doGet('/serverTimingDisabledTest/slowAction') + def response = doGet('/serverTimingDisabledTest/slowAction') then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null @@ -44,7 +44,7 @@ class ServerTimingDisabledIntegrationSpec extends Specification { void "JSON response should NOT include Server Timing header when plugin is disabled"() { when: 'we request the JSON action' - ResponseEntity response = doGet('/serverTimingDisabledTest/jsonResponse') + def response = doGet('/serverTimingDisabledTest/jsonResponse') then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null @@ -52,7 +52,7 @@ class ServerTimingDisabledIntegrationSpec extends Specification { void "text response should NOT include Server Timing header when plugin is disabled"() { when: 'we request the text action' - ResponseEntity response = doGet('/serverTimingDisabledTest/textResponse') + def response = doGet('/serverTimingDisabledTest/textResponse') then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null @@ -60,7 +60,7 @@ class ServerTimingDisabledIntegrationSpec extends Specification { void "index page should NOT include Server Timing header when plugin is disabled"() { when: 'we request the index page' - ResponseEntity response = doGet('/') + def response = doGet('/') then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null @@ -68,7 +68,7 @@ class ServerTimingDisabledIntegrationSpec extends Specification { void "static asset should NOT include Server Timing header when plugin is disabled"() { when: 'we request a static asset' - ResponseEntity response = doGet('/assets/application.css?compile=false') + def response = doGet('/assets/application.css?compile=false') then: 'the response should NOT have a Server Timing header' response.headers.getFirst('Server-Timing') == null diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index 1c4a991..4285860 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -29,7 +29,7 @@ class ServerTimingInterceptor implements Interceptor { @Override boolean before() { - TimingMetric timing = request.getAttribute(metricKey) as TimingMetric + def timing = request.getAttribute(metricKey) as TimingMetric if (timing) { timing.create('action', 'Action') .start() @@ -44,7 +44,7 @@ class ServerTimingInterceptor implements Interceptor { return true } - TimingMetric timing = request.getAttribute(metricKey) as TimingMetric + def timing = request.getAttribute(metricKey) as TimingMetric if (timing) { timing.get('action')?.stop() timing.create('view', 'View') diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index 6546912..c90f4dd 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy @@ -37,24 +37,24 @@ class ServerTimingFilter implements Filter, Ordered { throws IOException, ServletException { if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { - log.warn("Could not apply Server Timing Filter because request or response was not an expected HttpServlet type: {} / {}", request.class, response.class) + log.warn('Could not apply Server Timing Filter because request or response was not an expected HttpServlet type: {} / {}', request.class, response.class) chain.doFilter(request, response) return } - HttpServletRequest httpRequest = (HttpServletRequest) request - HttpServletResponse httpResponse = (HttpServletResponse) response + def httpRequest = (HttpServletRequest) request + def httpResponse = (HttpServletResponse) response // Create the timing metric and store it in the request // The interceptor will add 'action' and 'view' metrics for controller requests // For non-controller requests (static resources), we track as 'other' - TimingMetric timing = new TimingMetric() + def timing = new TimingMetric() httpRequest.setAttribute(metricKey, timing) timing.create('total', 'Total').start() timing.create('other', 'Non-Grails Controller Action/View').start() // Wrap the response to intercept commits and add the header - ServerTimingResponseWrapper wrappedResponse = new ServerTimingResponseWrapper(httpResponse, timing) + def wrappedResponse = new ServerTimingResponseWrapper(httpResponse, timing) try { chain.doFilter(request, wrappedResponse) } finally { diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy index a5f31af..0651276 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy @@ -6,7 +6,6 @@ import jakarta.servlet.ServletOutputStream import jakarta.servlet.WriteListener import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponseWrapper -import org.grails.plugins.servertiming.core.Metric import org.grails.plugins.servertiming.core.TimingMetric /** @@ -40,7 +39,7 @@ class ServerTimingResponseWrapper extends HttpServletResponseWrapper { stopTimings() - String headerValue = timing.toHeaderValue() + def headerValue = timing.toHeaderValue() log.trace('{} header value: {}', ServerTimingInterceptor.HEADER_NAME, headerValue) if (headerValue) { originalResponse.addHeader(ServerTimingInterceptor.HEADER_NAME, headerValue) @@ -58,13 +57,13 @@ class ServerTimingResponseWrapper extends HttpServletResponseWrapper { timing.get('other')?.stop() } - Metric actionTiming = timing.get('action') + def actionTiming = timing.get('action') if (actionTiming?.running) { actionTiming.stop() } // view won't exist if the action committed the request - Metric viewTiming = timing.get('view') + def viewTiming = timing.get('view') if (viewTiming?.running) { viewTiming.stop() } diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy index d985927..0bef824 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy @@ -62,13 +62,13 @@ class Metric implements Validateable, Serializable { throw new IllegalStateException('The metric has not been started yet.') } - long elapsedNanos = System.nanoTime() - startTimeNanos + def elapsedNanos = System.nanoTime() - startTimeNanos return Duration.ofNanos(elapsedNanos) } Metric stop() { if (startTimeNanos != null) { - long elapsedNanos = System.nanoTime() - startTimeNanos + def elapsedNanos = System.nanoTime() - startTimeNanos duration = Duration.ofNanos(elapsedNanos) } return this @@ -83,21 +83,21 @@ class Metric implements Validateable, Serializable { } String toHeaderValue() { - List parts = [name] + def parts = [name] if (running) { // if started, require a stop() throw new IllegalStateException("The metric [${name}] has not been stopped yet.") } if (ran) { - long nanos = duration.toNanos() - double millis = nanos / 1_000_000.0d + def nanos = duration.toNanos() + def millis = nanos / 1_000_000.0d parts << "dur=${millis.round(1)}".toString() } if (description) { // Escape backslashes first, then quotes per RFC 7230 quoted-string - String escapedDesc = description + def escapedDesc = description .replace('\\', '\\\\') .replace('"', '\\"') parts << "desc=\"${escapedDesc}\"".toString() @@ -110,7 +110,7 @@ class Metric implements Validateable, Serializable { if (this.is(o)) return true if (o == null || getClass() != o.class) return false - Metric metric = (Metric) o + def metric = (Metric) o if (key != metric.key) return false diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy index 45f9987..596d2fb 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/TimingMetric.groovy @@ -16,7 +16,7 @@ class TimingMetric implements Serializable { private LinkedHashMap metrics = [:] Metric create(String name, String description = null) { - Metric metric = new Metric(name: name, description: description) + def metric = new Metric(name: name, description: description) metrics.put(name, metric) if (!metric.validate()) { diff --git a/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy b/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy index 7d60ee3..5a3852e 100644 --- a/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy +++ b/plugin/src/test/groovy/org/grails/plugins/servertiming/MetricSpec.groovy @@ -10,7 +10,7 @@ class MetricSpec extends Specification { def "test basic metric creation with name"() { when: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') then: metric.name == 'testMetric' @@ -19,7 +19,7 @@ class MetricSpec extends Specification { def "test metric creation with name and description"() { when: - Metric metric = new Metric(name: 'testMetric', description: 'Test Description') + def metric = new Metric(name: 'testMetric', description: 'Test Description') then: metric.name == 'testMetric' @@ -30,7 +30,7 @@ class MetricSpec extends Specification { @Unroll def "test valid metric names: #name"() { when: - Metric metric = new Metric(name: name) + def metric = new Metric(name: name) then: metric.validate() @@ -64,7 +64,7 @@ class MetricSpec extends Specification { @Unroll def "test invalid metric names: #name"() { when: - Metric metric = new Metric(name: name) + def metric = new Metric(name: name) then: !metric.validate() @@ -98,7 +98,7 @@ class MetricSpec extends Specification { def "test description can be null"() { when: - Metric metric = new Metric(name: 'testMetric', description: null) + def metric = new Metric(name: 'testMetric', description: null) then: metric.validate() @@ -106,7 +106,7 @@ class MetricSpec extends Specification { def "test description cannot be blank"() { when: - Metric metric = new Metric(name: 'testMetric', description: '') + def metric = new Metric(name: 'testMetric', description: '') then: !metric.validate() @@ -115,7 +115,7 @@ class MetricSpec extends Specification { def "test start() initializes timing"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') when: def result = metric.start() @@ -126,7 +126,7 @@ class MetricSpec extends Specification { def "test start() throws exception if already started"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') metric.start() metric.stop() @@ -139,7 +139,7 @@ class MetricSpec extends Specification { def "test stop() calculates duration"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') when: metric.start() @@ -153,7 +153,7 @@ class MetricSpec extends Specification { def "test stop() returns self for chaining"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') metric.start() when: @@ -165,7 +165,7 @@ class MetricSpec extends Specification { def "test stop() does nothing if not started"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') when: metric.stop() @@ -176,12 +176,12 @@ class MetricSpec extends Specification { def "test calculateElapsedTime() returns elapsed time while running"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') metric.start() when: Thread.sleep(50) - Duration elapsed = metric.calculateElapsedTime() + def elapsed = metric.calculateElapsedTime() then: elapsed != null @@ -190,7 +190,7 @@ class MetricSpec extends Specification { def "test calculateElapsedTime() throws an exception if not started"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') when: metric.calculateElapsedTime() @@ -201,10 +201,10 @@ class MetricSpec extends Specification { def "test toHeaderValue() with name only"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'testMetric' @@ -212,10 +212,10 @@ class MetricSpec extends Specification { def "test toHeaderValue() with name and description"() { given: - Metric metric = new Metric(name: 'testMetric', description: 'Test Description') + def metric = new Metric(name: 'testMetric', description: 'Test Description') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'testMetric;desc="Test Description"' @@ -223,12 +223,12 @@ class MetricSpec extends Specification { def "test toHeaderValue() with name and duration"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') metric.start() metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header.startsWith('testMetric;dur=') @@ -236,12 +236,12 @@ class MetricSpec extends Specification { def "test toHeaderValue() with name, description and duration"() { given: - Metric metric = new Metric(name: 'testMetric', description: 'Test Description') + def metric = new Metric(name: 'testMetric', description: 'Test Description') metric.start() metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header.startsWith('testMetric;dur=') @@ -250,7 +250,7 @@ class MetricSpec extends Specification { def "test toHeaderValue() throws exception if started but not stopped"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') metric.start() when: @@ -262,8 +262,8 @@ class MetricSpec extends Specification { def "test equals() with same name (case insensitive)"() { given: - Metric metric1 = new Metric(name: 'testMetric') - Metric metric2 = new Metric(name: 'TESTMETRIC') + def metric1 = new Metric(name: 'testMetric') + def metric2 = new Metric(name: 'TESTMETRIC') expect: metric1 == metric2 @@ -271,8 +271,8 @@ class MetricSpec extends Specification { def "test equals() with different names"() { given: - Metric metric1 = new Metric(name: 'testMetric1') - Metric metric2 = new Metric(name: 'testMetric2') + def metric1 = new Metric(name: 'testMetric1') + def metric2 = new Metric(name: 'testMetric2') expect: metric1 != metric2 @@ -280,7 +280,7 @@ class MetricSpec extends Specification { def "test equals() with same instance"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') expect: metric == metric @@ -288,7 +288,7 @@ class MetricSpec extends Specification { def "test equals() with null"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') expect: metric != null @@ -296,7 +296,7 @@ class MetricSpec extends Specification { def "test equals() with different class"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') expect: !metric.equals('testMetric') @@ -304,8 +304,8 @@ class MetricSpec extends Specification { def "test hashCode() consistency"() { given: - Metric metric1 = new Metric(name: 'testMetric') - Metric metric2 = new Metric(name: 'TESTMETRIC') + def metric1 = new Metric(name: 'testMetric') + def metric2 = new Metric(name: 'TESTMETRIC') expect: metric1.hashCode() == metric2.hashCode() @@ -313,7 +313,7 @@ class MetricSpec extends Specification { def "test hashCode() with null key"() { given: - Metric metric = new Metric() + def metric = new Metric() expect: metric.hashCode() == 0 @@ -321,17 +321,17 @@ class MetricSpec extends Specification { def "test metric is serializable"() { given: - Metric metric = new Metric(name: 'testMetric', description: 'Test Description') + def metric = new Metric(name: 'testMetric', description: 'Test Description') when: - ByteArrayOutputStream bos = new ByteArrayOutputStream() - ObjectOutputStream oos = new ObjectOutputStream(bos) + def bos = new ByteArrayOutputStream() + def oos = new ObjectOutputStream(bos) oos.writeObject(metric) oos.close() - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()) - ObjectInputStream ois = new ObjectInputStream(bis) - Metric deserializedMetric = (Metric) ois.readObject() + def bis = new ByteArrayInputStream(bos.toByteArray()) + def ois = new ObjectInputStream(bis) + def deserializedMetric = (Metric) ois.readObject() then: deserializedMetric.name == 'testMetric' @@ -340,7 +340,7 @@ class MetricSpec extends Specification { def "test start() and stop() chaining"() { given: - Metric metric = new Metric(name: 'testMetric') + def metric = new Metric(name: 'testMetric') when: metric.start().stop() @@ -354,13 +354,13 @@ class MetricSpec extends Specification { def "test toHeaderValue() duration is in milliseconds with decimal precision"() { given: - Metric metric = new Metric(name: 'db') + def metric = new Metric(name: 'db') metric.start() Thread.sleep(10) metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: // Should be in format: name;dur=X.X where X.X is milliseconds @@ -369,12 +369,12 @@ class MetricSpec extends Specification { def "test toHeaderValue() format matches Server Timing spec"() { given: - Metric metric = new Metric(name: 'cache', description: 'Cache Read') + def metric = new Metric(name: 'cache', description: 'Cache Read') metric.start() metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: // Format should be: metric-name;dur=value;desc="description" @@ -383,10 +383,10 @@ class MetricSpec extends Specification { def "test description with special characters is properly quoted"() { given: - Metric metric = new Metric(name: 'api', description: 'API Call: GET /users') + def metric = new Metric(name: 'api', description: 'API Call: GET /users') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'api;desc="API Call: GET /users"' @@ -394,12 +394,12 @@ class MetricSpec extends Specification { def "test metric with zero duration"() { given: - Metric metric = new Metric(name: 'instant') + def metric = new Metric(name: 'instant') metric.start() metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: // Even very small durations should output dur= @@ -408,8 +408,8 @@ class MetricSpec extends Specification { def "test multiple metrics can be created independently"() { given: - Metric metric1 = new Metric(name: 'db', description: 'Database') - Metric metric2 = new Metric(name: 'cache', description: 'Cache') + def metric1 = new Metric(name: 'db', description: 'Database') + def metric2 = new Metric(name: 'cache', description: 'Cache') metric1.start() metric2.start() @@ -427,7 +427,7 @@ class MetricSpec extends Specification { def "test metric name follows token format per RFC 7230"() { when: - Metric metric = new Metric(name: 'my-metric_123') + def metric = new Metric(name: 'my-metric_123') then: metric.validate() @@ -436,10 +436,10 @@ class MetricSpec extends Specification { def "test duration is not included if metric was never started"() { given: - Metric metric = new Metric(name: 'skipped', description: 'Skipped operation') + def metric = new Metric(name: 'skipped', description: 'Skipped operation') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'skipped;desc="Skipped operation"' @@ -449,10 +449,10 @@ class MetricSpec extends Specification { def "test header value with only name is valid per spec"() { // Server Timing allows metrics with just a name (no dur or desc) given: - Metric metric = new Metric(name: 'miss') + def metric = new Metric(name: 'miss') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'miss' @@ -460,12 +460,12 @@ class MetricSpec extends Specification { def "test header value with name and duration only"() { given: - Metric metric = new Metric(name: 'total') + def metric = new Metric(name: 'total') metric.start() metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header ==~ /total;dur=\d+\.\d/ @@ -475,10 +475,10 @@ class MetricSpec extends Specification { def "test description with embedded quotes should be escaped"() { // Per RFC 7230, quoted strings must escape embedded quotes with backslash given: - Metric metric = new Metric(name: 'api', description: 'Said "Hello"') + def metric = new Metric(name: 'api', description: 'Said "Hello"') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'api;desc="Said \\"Hello\\""' @@ -487,10 +487,10 @@ class MetricSpec extends Specification { def "test description with backslashes should be escaped"() { // Per RFC 7230, backslashes in quoted strings must be escaped given: - Metric metric = new Metric(name: 'path', description: 'C:\\Users\\test') + def metric = new Metric(name: 'path', description: 'C:\\Users\\test') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'path;desc="C:\\\\Users\\\\test"' @@ -498,10 +498,10 @@ class MetricSpec extends Specification { def "test description with both quotes and backslashes"() { given: - Metric metric = new Metric(name: 'complex', description: 'Path: "C:\\temp"') + def metric = new Metric(name: 'complex', description: 'Path: "C:\\temp"') when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: header == 'complex;desc="Path: \\"C:\\\\temp\\""' @@ -509,13 +509,13 @@ class MetricSpec extends Specification { def "test sub-millisecond duration precision"() { given: - Metric metric = new Metric(name: 'fast') + def metric = new Metric(name: 'fast') metric.start() // Don't sleep - capture very fast operation metric.stop() when: - String header = metric.toHeaderValue() + def header = metric.toHeaderValue() then: // Should still produce valid output even for sub-millisecond durations @@ -524,14 +524,14 @@ class MetricSpec extends Specification { def "test duration value format is decimal"() { given: - Metric metric = new Metric(name: 'test') + def metric = new Metric(name: 'test') metric.start() Thread.sleep(5) metric.stop() when: - String header = metric.toHeaderValue() - String durValue = (header =~ /dur=(\d+\.\d)/)[0][1] + def header = metric.toHeaderValue() + def durValue = (header =~ /dur=(\d+\.\d)/)[0][1] then: // Duration should be parseable as a decimal number @@ -540,10 +540,10 @@ class MetricSpec extends Specification { def "test metric can be used in Set due to equals/hashCode contract"() { given: - Set metrics = new HashSet<>() - Metric metric1 = new Metric(name: 'db') - Metric metric2 = new Metric(name: 'DB') // Same key (case insensitive) - Metric metric3 = new Metric(name: 'cache') + def metrics = new HashSet() + def metric1 = new Metric(name: 'db') + def metric2 = new Metric(name: 'DB') // Same key (case insensitive) + def metric3 = new Metric(name: 'cache') when: metrics.add(metric1) diff --git a/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy b/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy index 6430d41..87fc01f 100644 --- a/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy +++ b/plugin/src/test/groovy/org/grails/plugins/servertiming/TimingMetricSpec.groovy @@ -9,10 +9,10 @@ class TimingMetricSpec extends Specification { def "test create() returns a new Metric"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: - Metric metric = timingMetric.create('db') + def metric = timingMetric.create('db') then: metric != null @@ -21,10 +21,10 @@ class TimingMetricSpec extends Specification { def "test create() with name and description"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: - Metric metric = timingMetric.create('db', 'Database Query') + def metric = timingMetric.create('db', 'Database Query') then: metric != null @@ -34,11 +34,11 @@ class TimingMetricSpec extends Specification { def "test create() stores metric for later retrieval"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: - Metric created = timingMetric.create('cache') - Metric retrieved = timingMetric.get('cache') + def created = timingMetric.create('cache') + def retrieved = timingMetric.get('cache') then: created.is(retrieved) @@ -46,10 +46,10 @@ class TimingMetricSpec extends Specification { def "test create() indicates metric exists"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: - Metric created = timingMetric.create('cache') + def created = timingMetric.create('cache') then: timingMetric.has('cache') @@ -57,7 +57,7 @@ class TimingMetricSpec extends Specification { def "test remove() removes a metric"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: timingMetric.create('cache') @@ -69,7 +69,7 @@ class TimingMetricSpec extends Specification { def "test create() throws ValidationException for invalid metric name"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: timingMetric.create('invalid name') // space not allowed @@ -80,7 +80,7 @@ class TimingMetricSpec extends Specification { def "test create() throws ValidationException for null name"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: timingMetric.create(null) @@ -91,7 +91,7 @@ class TimingMetricSpec extends Specification { def "test create() throws ValidationException for blank name"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: timingMetric.create('') @@ -102,7 +102,7 @@ class TimingMetricSpec extends Specification { def "test create() throws ValidationException for blank description"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: timingMetric.create('valid', '') @@ -113,10 +113,10 @@ class TimingMetricSpec extends Specification { def "test create() allows null description"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: - Metric metric = timingMetric.create('valid', null) + def metric = timingMetric.create('valid', null) then: notThrown(ValidationException) @@ -125,11 +125,11 @@ class TimingMetricSpec extends Specification { def "test create() overwrites existing metric with same name"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: - Metric first = timingMetric.create('db', 'First') - Metric second = timingMetric.create('db', 'Second') + def first = timingMetric.create('db', 'First') + def second = timingMetric.create('db', 'Second') then: timingMetric.get('db').is(second) @@ -138,7 +138,7 @@ class TimingMetricSpec extends Specification { def "test get() returns null for non-existent metric"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() expect: timingMetric.get('nonexistent') == null @@ -146,7 +146,7 @@ class TimingMetricSpec extends Specification { def "test get() returns the correct metric"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('db', 'Database') timingMetric.create('cache', 'Cache') @@ -159,7 +159,7 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() returns null when no metrics"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() expect: timingMetric.toHeaderValue() == null @@ -167,7 +167,7 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() with single metric (name only)"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('miss') expect: @@ -176,7 +176,7 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() with single metric (name and description)"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('cache', 'Cache Read') expect: @@ -185,13 +185,13 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() with single metric (name and duration)"() { given: - TimingMetric timingMetric = new TimingMetric() - Metric metric = timingMetric.create('db') + def timingMetric = new TimingMetric() + def metric = timingMetric.create('db') metric.start() metric.stop() when: - String header = timingMetric.toHeaderValue() + def header = timingMetric.toHeaderValue() then: header ==~ /db;dur=\d+\.\d/ @@ -199,7 +199,7 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() with multiple metrics"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('db', 'Database') timingMetric.create('cache', 'Cache') @@ -209,18 +209,18 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() with multiple metrics including durations"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() - Metric db = timingMetric.create('db', 'Database') + def db = timingMetric.create('db', 'Database') db.start() db.stop() - Metric cache = timingMetric.create('cache', 'Cache') + def cache = timingMetric.create('cache', 'Cache') cache.start() cache.stop() when: - String header = timingMetric.toHeaderValue() + def header = timingMetric.toHeaderValue() then: header.contains('db;dur=') @@ -232,13 +232,13 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() preserves metric order"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('first') timingMetric.create('second') timingMetric.create('third') when: - String header = timingMetric.toHeaderValue() + def header = timingMetric.toHeaderValue() then: header == 'first,second,third' @@ -248,19 +248,19 @@ class TimingMetricSpec extends Specification { def "test TimingMetric is serializable"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('db', 'Database') timingMetric.create('cache', 'Cache') when: - ByteArrayOutputStream bos = new ByteArrayOutputStream() - ObjectOutputStream oos = new ObjectOutputStream(bos) + def bos = new ByteArrayOutputStream() + def oos = new ObjectOutputStream(bos) oos.writeObject(timingMetric) oos.close() - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()) - ObjectInputStream ois = new ObjectInputStream(bis) - TimingMetric deserialized = (TimingMetric) ois.readObject() + def bis = new ByteArrayInputStream(bos.toByteArray()) + def ois = new ObjectInputStream(bis) + def deserialized = (TimingMetric) ois.readObject() then: deserialized.get('db') != null @@ -272,9 +272,9 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() format matches Server Timing spec"() { // Server-Timing header format: metric-name;dur=value;desc="description", ... given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() - Metric db = timingMetric.create('db', 'Database Query') + def db = timingMetric.create('db', 'Database Query') db.start() Thread.sleep(5) db.stop() @@ -282,7 +282,7 @@ class TimingMetricSpec extends Specification { timingMetric.create('miss') when: - String header = timingMetric.toHeaderValue() + def header = timingMetric.toHeaderValue() then: // Should produce: db;dur=X.X;desc="Database Query",miss @@ -291,12 +291,12 @@ class TimingMetricSpec extends Specification { def "test toHeaderValue() handles special characters in descriptions"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() timingMetric.create('api', 'GET /users?id=1') timingMetric.create('db', 'Query: "SELECT *"') when: - String header = timingMetric.toHeaderValue() + def header = timingMetric.toHeaderValue() then: header.contains('api;desc="GET /users?id=1"') @@ -305,16 +305,16 @@ class TimingMetricSpec extends Specification { def "test typical usage pattern"() { given: - TimingMetric timingMetric = new TimingMetric() + def timingMetric = new TimingMetric() when: 'Create and time a database operation' - Metric db = timingMetric.create('db', 'Database') + def db = timingMetric.create('db', 'Database') db.start() Thread.sleep(10) db.stop() and: 'Create and time a cache operation' - Metric cache = timingMetric.create('cache', 'Cache') + def cache = timingMetric.create('cache', 'Cache') cache.start() Thread.sleep(5) cache.stop() @@ -323,7 +323,7 @@ class TimingMetricSpec extends Specification { timingMetric.create('miss') then: - String header = timingMetric.toHeaderValue() + def header = timingMetric.toHeaderValue() header.contains('db;dur=') header.contains('cache;dur=') header.contains('miss') From 3a49e8c78ad143a3b48d37bb345aa64cfe8fa4c9 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 14:44:01 -0500 Subject: [PATCH 13/47] feedback: prefer extensions.configure() instead of extensionName { } --- ...on-plugins.md => gradle-best-practices.md} | 46 +++++++++++++++++-- AGENTS.md | 2 +- ...s.servertiming.coverage-aggregation.gradle | 6 ++- ...g.grails.plugins.servertiming.style.gradle | 27 ++++++----- ...grails.plugins.servertiming.testing.gradle | 6 ++- 5 files changed, 67 insertions(+), 20 deletions(-) rename .skills/{gradle-convention-plugins.md => gradle-best-practices.md} (79%) diff --git a/.skills/gradle-convention-plugins.md b/.skills/gradle-best-practices.md similarity index 79% rename from .skills/gradle-convention-plugins.md rename to .skills/gradle-best-practices.md index cc98709..75dccb5 100644 --- a/.skills/gradle-convention-plugins.md +++ b/.skills/gradle-best-practices.md @@ -1,9 +1,10 @@ -# Gradle Convention Plugins Best Practices +# Gradle Best Practices ## Purpose -Convention plugins eliminate duplication across subprojects by centralizing shared build logic. They live in the -`build-logic/` composite build and are applied by ID in each subproject's `build.gradle`. +This skill covers Gradle best practices for this project, including convention plugins, extension configuration, +lazy APIs, and build structure. Convention plugins eliminate duplication across subprojects by centralizing shared +build logic. They live in the `build-logic/` composite build and are applied by ID in each subproject's `build.gradle`. ## Core Rules @@ -150,6 +151,45 @@ Key APIs to use: - `project.provider {}` for lazy values - `layout.buildDirectory` instead of `buildDir` +## Extension Configuration with Type Hints + +When configuring project extensions (like publishing metadata or third-party plugin configurations), use +`extensions.configure(Type)` with explicit parameter hints for better IDE support and type safety: + +```groovy +// GOOD - explicit type hint in extension configuration +extensions.configure(GrailsPublishExtension) { + it.artifactId = project.name + it.githubSlug = 'grails-plugins/grails-server-timing' + it.license.name = 'Apache-2.0' + it.title = 'My Plugin' + it.developers = [name: 'Developer Name'] +} + +// GOOD - configuring standard Gradle extensions with type hints +tasks.named('bootRun', JavaExec).configure { + doFirst { + jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005") + } +} + +// GOOD - property-style DSL extensions without type hints (acceptable for simple cases) +checkstyle { + toolVersion = checkstyleVersion + maxWarnings = 0 +} +``` + +**Benefits:** + +- IDE auto-completion and type-checking for extension properties +- Clearer intent: code readers immediately see the extension type being configured +- Reduces runtime errors from typos in property names +- Works well with `@GrailsCompileStatic` in Groovy convention plugins + +Note: Simple property-style DSL configurations (like `checkstyle {}` or `jacoco {}`) don't require type hints—use +them when you're accessing nested properties or methods where IDE support is most valuable. + ## Composition Over Inheritance Convention plugins should compose by applying other convention plugins rather than duplicating logic: diff --git a/AGENTS.md b/AGENTS.md index afe3ad9..9f8b90b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Detailed best practices are documented in `.skills/`: | Skill File | Purpose | |--------------------------------------------------------------------------------|-------------------------------------------------------| | [`.skills/repository-structure.md`](.skills/repository-structure.md) | Canonical directory layout and architectural rules | -| [`.skills/gradle-convention-plugins.md`](.skills/gradle-convention-plugins.md) | Convention plugin patterns, naming, and anti-patterns | +| [`.skills/gradle-best-practices.md`](.skills/gradle-best-practices.md) | Gradle best practices, convention plugins, and idioms | | [`.skills/plugin-project.md`](.skills/plugin-project.md) | Plugin project scope: source code + unit tests only | | [`.skills/example-apps.md`](.skills/example-apps.md) | Example app patterns: integration & functional tests | diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle index db71206..277a4f7 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle @@ -1,10 +1,12 @@ +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension + plugins { id 'base' id 'jacoco' } -jacoco { - toolVersion = '0.8.12' +extensions.configure(JacocoPluginExtension) { + it.toolVersion = '0.8.12' } // Configuration for declaring which projects contribute coverage data. diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle index df9cec9..60bffcb 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle @@ -1,3 +1,6 @@ +import org.gradle.api.plugins.quality.CheckstyleExtension +import org.gradle.api.plugins.quality.CodeNarcExtension + plugins { id 'checkstyle' id 'codenarc' @@ -8,12 +11,12 @@ def codeStyleConfigDir = rootProject.file('build-logic/config') def checkstyleConfigDir = new File(codeStyleConfigDir, 'checkstyle') def codenarcConfigDir = new File(codeStyleConfigDir, 'codenarc') -checkstyle { - toolVersion = checkstyleVersion - configDirectory = checkstyleConfigDir - maxWarnings = 0 - showViolations = true - ignoreFailures = false +extensions.configure(CheckstyleExtension) { + it.toolVersion = checkstyleVersion + it.configDirectory = checkstyleConfigDir + it.maxWarnings = 0 + it.showViolations = true + it.ignoreFailures = false } tasks.withType(Checkstyle).configureEach { @@ -21,12 +24,12 @@ tasks.withType(Checkstyle).configureEach { it.onlyIf { !project.hasProperty('skipCodeStyle') } } -codenarc { - toolVersion = codenarcVersion - configFile = new File(codenarcConfigDir, 'codenarc.groovy') - maxPriority1Violations = 0 - maxPriority2Violations = 0 - maxPriority3Violations = 0 +extensions.configure(CodeNarcExtension) { + it.toolVersion = codenarcVersion + it.configFile = new File(codenarcConfigDir, 'codenarc.groovy') + it.maxPriority1Violations = 0 + it.maxPriority2Violations = 0 + it.maxPriority3Violations = 0 } tasks.withType(CodeNarc).configureEach { diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle index ed544c0..0dead6f 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle @@ -1,3 +1,5 @@ +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension + plugins { id 'com.adarshr.test-logger' id 'jacoco' @@ -16,8 +18,8 @@ testlogger { showFailed true } -jacoco { - toolVersion = '0.8.12' +extensions.configure(JacocoPluginExtension) { + it.toolVersion = '0.8.12' } tasks.withType(Test).configureEach { From ad3d84ee1217626f5c48908ca35aea5fe905d03c Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 14:45:56 -0500 Subject: [PATCH 14/47] feedback: use dependsOn() instead of setter --- .skills/gradle-best-practices.md | 3 ++- .../groovy/org.grails.plugins.servertiming.docs.gradle | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.skills/gradle-best-practices.md b/.skills/gradle-best-practices.md index 75dccb5..30d06f2 100644 --- a/.skills/gradle-best-practices.md +++ b/.skills/gradle-best-practices.md @@ -130,7 +130,7 @@ tasks.named('bootRun', JavaExec).configure { } tasks.register('docs') { - dependsOn = [/* ... */] + dependsOn(/* ... */) } // BAD - eager resolution @@ -150,6 +150,7 @@ Key APIs to use: - `tasks.withType(X).configureEach {}` instead of `tasks.withType(X) {}` - `project.provider {}` for lazy values - `layout.buildDirectory` instead of `buildDir` +- `dependsOn()` method instead of `dependsOn =` setter (setter replaces all dependencies; the method adds to them) ## Extension Configuration with Type Hints diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle index 82799af..76eaf52 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle @@ -11,7 +11,7 @@ tasks.register('cleanDocs', Delete) { tasks.register('aggregateGroovyApiDoc', Groovydoc) { def groovyDocProjects = [pluginProject.get()] - dependsOn = [tasks.named('cleanDocs'), pluginProject.get().tasks.named('groovydoc')] + dependsOn(tasks.named('cleanDocs'), pluginProject.get().tasks.named('groovydoc')) description = 'Generates Groovy API Documentation for all plugin projects under rootDir/gapi' @@ -29,13 +29,13 @@ tasks.register('aggregateGroovyApiDoc', Groovydoc) { tasks.register('docs') { group = JavaBasePlugin.DOCUMENTATION_GROUP - dependsOn = ['aggregateGroovyApiDoc', docProject.get().tasks.named('asciidoctor')] + dependsOn('aggregateGroovyApiDoc', docProject.get().tasks.named('asciidoctor')) finalizedBy 'copyAsciiDoctorDocs', 'ghPagesRootIndexPage' } tasks.register('copyAsciiDoctorDocs', Copy) { group = JavaBasePlugin.DOCUMENTATION_GROUP - dependsOn = ['docs'] + dependsOn('docs') from docProject.get().layout.buildDirectory includes = ['docs/**'] into rootProject.layout.buildDirectory @@ -45,7 +45,7 @@ tasks.register('copyAsciiDoctorDocs', Copy) { // provides a root index page for historical versions that are currently managed manually tasks.register('ghPagesRootIndexPage', Copy) { group = 'documentation' - dependsOn = ['docs'] + dependsOn('docs') from docProject.get().layout.projectDirectory.file('src/docs/index.tmpl') into rootProject.layout.buildDirectory.dir('docs') rename 'index.tmpl', 'ghpages.html' From e1af184698b48d0846441d3982cac18864075fdb Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 14:50:49 -0500 Subject: [PATCH 15/47] feedback: removing explicit types & it where possible --- .skills/gradle-best-practices.md | 33 ++++++++++--------- ...lugins.servertiming.project-publish.gradle | 2 +- ...org.grails.plugins.servertiming.run.gradle | 2 +- ...g.grails.plugins.servertiming.style.gradle | 10 +++--- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.skills/gradle-best-practices.md b/.skills/gradle-best-practices.md index 30d06f2..6cb2058 100644 --- a/.skills/gradle-best-practices.md +++ b/.skills/gradle-best-practices.md @@ -125,7 +125,7 @@ tasks.withType(JavaCompile).configureEach { options.encoding = StandardCharsets.UTF_8.name() } -tasks.named('bootRun', JavaExec).configure { +tasks.named('bootRun', JavaExec) { doFirst { /* ... */ } } @@ -151,14 +151,15 @@ Key APIs to use: - `project.provider {}` for lazy values - `layout.buildDirectory` instead of `buildDir` - `dependsOn()` method instead of `dependsOn =` setter (setter replaces all dependencies; the method adds to them) +- Do NOT chain `.configure {}` on `tasks.register()` or `tasks.named()` — pass the closure directly to preserve type hints ## Extension Configuration with Type Hints When configuring project extensions (like publishing metadata or third-party plugin configurations), use -`extensions.configure(Type)` with explicit parameter hints for better IDE support and type safety: +`extensions.configure(Type)` with explicit `it` for type hints and better IDE support: ```groovy -// GOOD - explicit type hint in extension configuration +// GOOD - explicit it in extensions.configure() for type hints extensions.configure(GrailsPublishExtension) { it.artifactId = project.name it.githubSlug = 'grails-plugins/grails-server-timing' @@ -166,30 +167,30 @@ extensions.configure(GrailsPublishExtension) { it.title = 'My Plugin' it.developers = [name: 'Developer Name'] } +``` + +Explicit `it` is NOT required in `tasks.named()`, `tasks.register()`, or `configureEach` — these already have typed +delegates: + +```groovy +// GOOD - no explicit it needed, delegate is already typed +tasks.withType(Checkstyle).configureEach { + group = 'verification' + onlyIf { !project.hasProperty('skipCodeStyle') } +} -// GOOD - configuring standard Gradle extensions with type hints -tasks.named('bootRun', JavaExec).configure { +tasks.named('bootRun', JavaExec) { doFirst { jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005") } } - -// GOOD - property-style DSL extensions without type hints (acceptable for simple cases) -checkstyle { - toolVersion = checkstyleVersion - maxWarnings = 0 -} ``` -**Benefits:** +**Benefits of `extensions.configure(Type)` with explicit `it`:** - IDE auto-completion and type-checking for extension properties - Clearer intent: code readers immediately see the extension type being configured - Reduces runtime errors from typos in property names -- Works well with `@GrailsCompileStatic` in Groovy convention plugins - -Note: Simple property-style DSL configurations (like `checkstyle {}` or `jacoco {}`) don't require type hints—use -them when you're accessing nested properties or methods where IDE support is most valuable. ## Composition Over Inheritance diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle index 7051a35..5e4a7ef 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle @@ -22,7 +22,7 @@ project.pluginManager.withPlugin('signing') { if (System.getenv('DISABLE_BUILD_SIGNING')) { project.logger.lifecycle('Signing is disabled for this build per configuration.') project.tasks.withType(Sign).configureEach { - it.enabled = false + enabled = false } } } \ No newline at end of file diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle index 0797e21..66719b6 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle @@ -1,4 +1,4 @@ -tasks.named('bootRun', JavaExec).configure { +tasks.named('bootRun', JavaExec) { doFirst { if (project.hasProperty("debugWait")) { jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005") diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle index 60bffcb..1e1359c 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle @@ -20,8 +20,8 @@ extensions.configure(CheckstyleExtension) { } tasks.withType(Checkstyle).configureEach { - it.group = 'verification' - it.onlyIf { !project.hasProperty('skipCodeStyle') } + group = 'verification' + onlyIf { !project.hasProperty('skipCodeStyle') } } extensions.configure(CodeNarcExtension) { @@ -33,11 +33,11 @@ extensions.configure(CodeNarcExtension) { } tasks.withType(CodeNarc).configureEach { - it.group = 'verification' - it.onlyIf { !project.hasProperty('skipCodeStyle') } + group = 'verification' + onlyIf { !project.hasProperty('skipCodeStyle') } } -tasks.register('codeStyle').configure { +tasks.register('codeStyle') { group = 'verification' description = 'Runs all code style checks (Checkstyle + CodeNarc).' dependsOn tasks.withType(Checkstyle) From 6454debf3ea56686cdde591d69adf4d776ed7766 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 14:53:37 -0500 Subject: [PATCH 16/47] feedback: fix display on windows due to unicode usage --- .../groovy/org.grails.plugins.servertiming.testing.gradle | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle index 0dead6f..47304a3 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle +++ b/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle @@ -5,11 +5,14 @@ plugins { id 'jacoco' } -boolean isCi = System.getenv('CI') != null +def isCi = System.getenv('CI') != null +def isWindows = System.getProperty('os.name')?.toLowerCase()?.contains('windows') // This configures the 'pretty' test logging +// mocha-parallel uses Unicode symbols that require special config on Windows; +// standard-parallel is a safe fallback there. testlogger { - theme isCi ? 'plain-parallel' : 'mocha-parallel' + theme isCi ? 'plain-parallel' : (isWindows ? 'standard-parallel' : 'mocha-parallel') showExceptions true showStandardStreams false showSummary true From 471894a2f5f7bddff8ed6bd053bd8fbf358e6aa8 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 15:36:06 -0500 Subject: [PATCH 17/47] feedback: add auto configuration instead of using the grails plugin descriptor --- AGENTS.md | 34 +++++++----- docs/src/docs/how-it-works.adoc | 12 +++-- .../ServerTimingAutoConfiguration.groovy | 46 ++++++++++++++++ .../ServerTimingEnabledCondition.groovy | 35 ++++++++++++ .../ServerTimingGrailsPlugin.groovy | 54 +++---------------- ...ot.autoconfigure.AutoConfiguration.imports | 1 + 6 files changed, 118 insertions(+), 64 deletions(-) create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy create mode 100644 plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/AGENTS.md b/AGENTS.md index 9f8b90b..49a9ac2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,27 +104,33 @@ Run `sdk env install` to set up the environment. ## Architecture -The plugin intercepts HTTP requests via a servlet filter and Grails interceptor: +The plugin intercepts HTTP requests via a servlet filter and Grails interceptor. Bean wiring uses +Spring Boot `@AutoConfiguration` (not the Grails plugin descriptor's `doWithSpring()`): -1. **`ServerTimingFilter`** (servlet filter, highest precedence + 100) wraps every request, starts `total` and `other` +1. **`ServerTimingAutoConfiguration`** (Spring Boot auto-configuration) conditionally registers the + `ServerTimingFilter` and its `FilterRegistrationBean` when enabled, gated by + `ServerTimingEnabledCondition`. +2. **`ServerTimingFilter`** (servlet filter, highest precedence + 100) wraps every request, starts `total` and `other` timers. -2. **`ServerTimingInterceptor`** (Grails interceptor) starts an `action` timer in `before()`, stops it and starts a +3. **`ServerTimingInterceptor`** (Grails interceptor) starts an `action` timer in `before()`, stops it and starts a `view` timer in `after()`. -3. **`ServerTimingResponseWrapper`** intercepts response commit to inject the `Server-Timing` header before the first +4. **`ServerTimingResponseWrapper`** intercepts response commit to inject the `Server-Timing` header before the first byte is written. -4. For non-controller requests (e.g., static assets), only `total` and `other` metrics appear. +5. For non-controller requests (e.g., static assets), only `total` and `other` metrics appear. ### Core Classes (plugin/src/main/groovy/org/grails/plugins/servertiming/) -| Class | Purpose | -|----------------------------------|--------------------------------------------------------------------| -| `GrailsServerTimingGrailsPlugin` | Plugin descriptor; registers the filter bean when enabled | -| `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | -| `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | -| `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | -| `ServerTimingUtils` | Reads plugin configuration; auto-enables in DEV/TEST environments | -| `core/Metric` | Single timing metric model with RFC 7230 name validation | -| `core/TimingMetric` | Collection of metrics; generates header value | +| Class | Purpose | +|----------------------------------|---------------------------------------------------------------------------| +| `ServerTimingAutoConfiguration` | Spring Boot `@AutoConfiguration`; registers filter beans when enabled | +| `ServerTimingEnabledCondition` | Spring `Condition`; checks enabled via Spring Environment + Grails env | +| `ServerTimingGrailsPlugin` | Plugin descriptor; metadata only (no bean wiring) | +| `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | +| `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | +| `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | +| `ServerTimingUtils` | Reads plugin configuration; auto-enables in DEV/TEST environments | +| `core/Metric` | Single timing metric model with RFC 7230 name validation | +| `core/TimingMetric` | Collection of metrics; generates header value | ## Configuration diff --git a/docs/src/docs/how-it-works.adoc b/docs/src/docs/how-it-works.adoc index 77f4501..f5967f2 100644 --- a/docs/src/docs/how-it-works.adoc +++ b/docs/src/docs/how-it-works.adoc @@ -4,11 +4,15 @@ This section explains the architecture of the plugin and how it handles differen === Architecture Overview -The plugin consists of three main components that work together: +The plugin consists of four main components that work together: -1. **ServerTimingFilter** - A servlet filter that wraps all HTTP responses -2. **ServerTimingInterceptor** - A Grails interceptor that tracks controller action and view timing -3. **ServerTimingResponseWrapper** - A response wrapper that ensures headers are added before the response is committed +1. **ServerTimingAutoConfiguration** - A Spring Boot `@AutoConfiguration` that conditionally registers the filter and its `FilterRegistrationBean` when the plugin is enabled (gated by `ServerTimingEnabledCondition`) +2. **ServerTimingFilter** - A servlet filter that wraps all HTTP responses +3. **ServerTimingInterceptor** - A Grails interceptor that tracks controller action and view timing +4. **ServerTimingResponseWrapper** - A response wrapper that ensures headers are added before the response is committed + +Bean wiring uses standard Spring Boot auto-configuration rather than the Grails plugin descriptor's `doWithSpring()`. +The `ServerTimingGrailsPlugin` class provides plugin metadata only. === Request Flow diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy new file mode 100644 index 0000000..5f07ab0 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy @@ -0,0 +1,46 @@ +package org.grails.plugins.servertiming + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Conditional + +/** + * Spring Boot auto-configuration for the Server Timing plugin. + * + *

    Registers a {@link ServerTimingFilter} and its {@link FilterRegistrationBean} + * when the plugin is enabled and the application is a servlet-based web application.

    + * + *

    Enablement is controlled by {@link ServerTimingEnabledCondition}, which delegates + * to {@link ServerTimingUtils#isEnabled()} — auto-enabled in {@code DEVELOPMENT} and + * {@code TEST} environments, disabled in {@code PRODUCTION}, unless explicitly + * overridden via {@code grails.plugins.serverTiming.enabled}.

    + * + * @see ServerTimingFilter + * @see ServerTimingEnabledCondition + * @see ServerTimingUtils + */ +@Slf4j +@CompileStatic +@AutoConfiguration +@Conditional(ServerTimingEnabledCondition) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +class ServerTimingAutoConfiguration { + + @Bean + ServerTimingFilter serverTimingFilter() { + new ServerTimingFilter() + } + + @Bean + FilterRegistrationBean serverTimingFilterRegistration(ServerTimingFilter serverTimingFilter) { + def registration = new FilterRegistrationBean(serverTimingFilter) + registration.urlPatterns = ['/*'] + registration.order = serverTimingFilter.order + registration.name = 'serverTimingFilter' + registration + } +} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy new file mode 100644 index 0000000..5bb83c6 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy @@ -0,0 +1,35 @@ +package org.grails.plugins.servertiming + +import grails.util.Environment +import groovy.transform.CompileStatic +import org.springframework.context.annotation.Condition +import org.springframework.context.annotation.ConditionContext +import org.springframework.core.type.AnnotatedTypeMetadata + +/** + * A Spring {@link Condition} that evaluates whether the Server Timing plugin is enabled. + * + *

    Checks the {@code grails.plugins.serverTiming.enabled} property from the Spring + * {@link org.springframework.core.env.Environment}. If the property is not set, defaults + * to enabled in {@code DEVELOPMENT} and {@code TEST} Grails environments and disabled + * in {@code PRODUCTION}.

    + * + *

    This condition reads directly from the Spring Environment rather than + * {@code Holders.config} because auto-configuration conditions are evaluated before + * the Grails config holder is initialized.

    + * + * @see ServerTimingAutoConfiguration + */ +@CompileStatic +class ServerTimingEnabledCondition implements Condition { + + @Override + boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + def explicitlyEnabled = context.environment.getProperty('grails.plugins.serverTiming.enabled', Boolean) + if (explicitlyEnabled != null) { + return explicitlyEnabled + } + + Environment.current in [Environment.DEVELOPMENT, Environment.TEST] + } +} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 13c1e68..5a99131 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -2,30 +2,19 @@ package org.grails.plugins.servertiming import grails.plugins.Plugin import groovy.util.logging.Slf4j -import org.springframework.boot.web.servlet.FilterRegistrationBean -import org.springframework.core.Ordered /** - * Grails plugin that provides Server Timing header support for HTTP responses. + * Grails plugin descriptor for the Server Timing plugin. * - *

    This plugin automatically registers a {@link ServerTimingFilter} that adds - * Server Timing headers to HTTP responses, - * allowing developers to communicate backend server performance metrics to the browser.

    + *

    Provides plugin metadata (version compatibility, author, documentation, etc.) + * for the Grails plugin framework.

    * - *

    Configuration

    - *

    The plugin can be enabled or disabled via the configuration property:

    - *
    - * grails.plugins.serverTiming.enabled = true
    - * 
    + *

    Bean wiring is handled by {@link ServerTimingAutoConfiguration} using standard + * Spring Boot auto-configuration rather than the Grails {@code doWithSpring()} mechanism.

    * - *

    Filter Registration

    - *

    When enabled, the plugin registers a servlet filter with the following characteristics:

    - *
      - *
    • URL Pattern: /* (applies to all requests)
    • - *
    • Order: Ordered.HIGHEST_PRECEDENCE + 100 (executes early in the filter chain)
    • - *
    - * - * @see ServerTimingFilter* @see ServerTimingUtils + * @see ServerTimingAutoConfiguration + * @see ServerTimingFilter + * @see ServerTimingUtils */ @Slf4j class ServerTimingGrailsPlugin extends Plugin { @@ -51,33 +40,6 @@ class ServerTimingGrailsPlugin extends Plugin { /** Source control management information */ def scm = [url: 'https://github.com/grails-plugins/grails-server-timing'] - /** - * Registers Spring beans for the Server Timing functionality. - * - *

    When the plugin is enabled, this method registers:

    - *
      - *
    • serverTimingFilter - The {@link ServerTimingFilter} bean
    • - *
    • serverTimingFilterRegistration - A {@link FilterRegistrationBean} - * that configures the filter to intercept all requests
    • - *
    - * - * @return a closure that defines the Spring bean configuration - */ - Closure doWithSpring() { - { -> - if (ServerTimingUtils.instance.enabled) { - serverTimingFilter(ServerTimingFilter) - - serverTimingFilterRegistration(FilterRegistrationBean) { - filter = ref('serverTimingFilter') - urlPatterns = ['/*'] - order = Ordered.HIGHEST_PRECEDENCE + 100 - name = 'serverTimingFilter' - } - } - } - } - /** * Performs initialization tasks after the Spring application context is available. * diff --git a/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..8fadc0c --- /dev/null +++ b/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.grails.plugins.servertiming.ServerTimingAutoConfiguration From 0ec228bab47262d99eab598e74309657c3dac67b Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 17 Feb 2026 15:38:47 -0500 Subject: [PATCH 18/47] feedback: use ubuntu-24.04 instead of ubuntu-latest --- .github/workflows/release-notes.yml | 2 +- .github/workflows/release.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 9491411..4951a17 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -18,7 +18,7 @@ jobs: permissions: contents: write # write permission is required to create a github release pull-requests: write # write permission is required for auto-labeler - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: "📝 Update Release Draft" uses: release-drafter/release-drafter@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 113354f..0274660 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,7 @@ jobs: name: "Make Release Files Available" environment: release # this step will be delayed until approved needs: [ publish ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: @@ -124,7 +124,7 @@ jobs: environment: docs # this step will be delayed until approved name: "Publish Documentation" needs: [ publish, release ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write # required to publish documentation to github pages branches steps: @@ -162,7 +162,7 @@ jobs: name: "To Next Version" environment: close # this step will be delayed until approved needs: [ publish, docs, release ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write # required for gradle.properties revert issues: write # required for milestone closing From 146a21a61638cec0158952b88a57acfdaa8a5e68 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 18 Feb 2026 12:32:42 -0500 Subject: [PATCH 19/47] feedback: use plugin configuration instead of helper --- .skills/plugin-project.md | 21 +++------- AGENTS.md | 15 +++---- docs/src/docs/how-it-works.adoc | 2 +- plugin/grails-app/conf/plugin.yml | 21 ++++++++++ .../ServerTimingInterceptor.groovy | 23 ++++++----- .../ServerTimingAutoConfiguration.groovy | 37 ++++++++++------- .../ServerTimingEnabledCondition.groovy | 35 ---------------- .../servertiming/ServerTimingFilter.groovy | 25 ++++++++---- .../ServerTimingGrailsPlugin.groovy | 9 +++-- .../ServerTimingProperties.groovy | 40 +++++++++++++++++++ .../servertiming/ServerTimingUtils.groovy | 26 ------------ 11 files changed, 134 insertions(+), 120 deletions(-) create mode 100644 plugin/grails-app/conf/plugin.yml delete mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy delete mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy diff --git a/.skills/plugin-project.md b/.skills/plugin-project.md index f5f276f..c8d52bb 100644 --- a/.skills/plugin-project.md +++ b/.skills/plugin-project.md @@ -50,10 +50,10 @@ plugin/ └── src/ ├── main/groovy/ # Core plugin classes │ └── org/grails/plugins/servertiming/ - │ ├── GrailsServerTimingGrailsPlugin.groovy + │ ├── ServerTimingAutoConfiguration.groovy │ ├── ServerTimingFilter.groovy + │ ├── ServerTimingProperties.groovy │ ├── ServerTimingResponseWrapper.groovy - │ ├── ServerTimingUtils.groovy │ └── core/ │ ├── Metric.groovy │ └── TimingMetric.groovy @@ -103,7 +103,6 @@ Key patterns: Unit tests in the plugin project test individual classes in isolation: - Test domain logic, validation, and data structures (e.g., `Metric`, `TimingMetric`) -- Test utility classes (e.g., `ServerTimingUtils`) - Use Spock Framework with `@Unroll` for data-driven tests - Do NOT start the Grails application context for unit tests - Do NOT make HTTP requests in unit tests @@ -127,18 +126,10 @@ Unit tests in the plugin project test individual classes in isolation: ## Plugin Descriptor -The `GrailsServerTimingGrailsPlugin` class extends `grails.plugins.Plugin` and registers Spring beans. It uses -`ServerTimingUtils` to check whether the plugin is enabled before registering the filter: - -```groovy -Closure doWithSpring() { - { -> - if (ServerTimingUtils.instance.isEnabled(grailsApplication)) { - // register filter beans - } - } -} -``` +The `ServerTimingGrailsPlugin` class extends `grails.plugins.Plugin` and provides plugin metadata. +Bean wiring is handled by `ServerTimingAutoConfiguration` using Spring Boot auto-configuration. +Configuration is managed by `ServerTimingProperties` (`@ConfigurationProperties`), with +environment-based defaults set in `plugin.yml`. ## Dependency Scoping diff --git a/AGENTS.md b/AGENTS.md index 49a9ac2..6455d47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,9 +107,9 @@ Run `sdk env install` to set up the environment. The plugin intercepts HTTP requests via a servlet filter and Grails interceptor. Bean wiring uses Spring Boot `@AutoConfiguration` (not the Grails plugin descriptor's `doWithSpring()`): -1. **`ServerTimingAutoConfiguration`** (Spring Boot auto-configuration) conditionally registers the - `ServerTimingFilter` and its `FilterRegistrationBean` when enabled, gated by - `ServerTimingEnabledCondition`. +1. **`ServerTimingAutoConfiguration`** (Spring Boot auto-configuration) unconditionally registers + `ServerTimingProperties` and conditionally registers the `ServerTimingFilter` and its + `FilterRegistrationBean` when enabled (via `@ConditionalOnProperty`). 2. **`ServerTimingFilter`** (servlet filter, highest precedence + 100) wraps every request, starts `total` and `other` timers. 3. **`ServerTimingInterceptor`** (Grails interceptor) starts an `action` timer in `before()`, stops it and starts a @@ -122,13 +122,12 @@ Spring Boot `@AutoConfiguration` (not the Grails plugin descriptor's `doWithSpri | Class | Purpose | |----------------------------------|---------------------------------------------------------------------------| -| `ServerTimingAutoConfiguration` | Spring Boot `@AutoConfiguration`; registers filter beans when enabled | -| `ServerTimingEnabledCondition` | Spring `Condition`; checks enabled via Spring Environment + Grails env | +| `ServerTimingAutoConfiguration` | Spring Boot `@AutoConfiguration`; registers properties and filter beans | +| `ServerTimingProperties` | `@ConfigurationProperties` for `grails.plugins.serverTiming.*` settings | | `ServerTimingGrailsPlugin` | Plugin descriptor; metadata only (no bean wiring) | | `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | | `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | | `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | -| `ServerTimingUtils` | Reads plugin configuration; auto-enables in DEV/TEST environments | | `core/Metric` | Single timing metric model with RFC 7230 name validation | | `core/TimingMetric` | Collection of metrics; generates header value | @@ -138,11 +137,13 @@ Set in `application.yml`: | Property | Default | Description | |-----------------------------------------|--------------------------------------------|-------------------------------------------| -| `grails.plugins.serverTiming.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | +| `grails.plugins.serverTiming.enabled` | `true` in DEV/TEST, `false` in PROD | Enable/disable the plugin | | `grails.plugins.serverTiming.metricKey` | `GrailsServerTiming` | Request attribute key for storing metrics | **Security note:** The plugin is disabled in production by default because timing data could facilitate timing attacks. +Default values per environment are set in `plugin.yml` using Grails environment-based configuration. + ## Testing ### Unit Tests (`plugin/src/test/`) diff --git a/docs/src/docs/how-it-works.adoc b/docs/src/docs/how-it-works.adoc index f5967f2..afbed8a 100644 --- a/docs/src/docs/how-it-works.adoc +++ b/docs/src/docs/how-it-works.adoc @@ -6,7 +6,7 @@ This section explains the architecture of the plugin and how it handles differen The plugin consists of four main components that work together: -1. **ServerTimingAutoConfiguration** - A Spring Boot `@AutoConfiguration` that conditionally registers the filter and its `FilterRegistrationBean` when the plugin is enabled (gated by `ServerTimingEnabledCondition`) +1. **ServerTimingAutoConfiguration** - A Spring Boot `@AutoConfiguration` that registers `ServerTimingProperties` and conditionally registers the filter and its `FilterRegistrationBean` when the plugin is enabled (via `@ConditionalOnProperty`) 2. **ServerTimingFilter** - A servlet filter that wraps all HTTP responses 3. **ServerTimingInterceptor** - A Grails interceptor that tracks controller action and view timing 4. **ServerTimingResponseWrapper** - A response wrapper that ensures headers are added before the response is committed diff --git a/plugin/grails-app/conf/plugin.yml b/plugin/grails-app/conf/plugin.yml new file mode 100644 index 0000000..2e83c3e --- /dev/null +++ b/plugin/grails-app/conf/plugin.yml @@ -0,0 +1,21 @@ +grails: + plugins: + serverTiming: + metricKey: GrailsServerTiming + +environments: + development: + grails: + plugins: + serverTiming: + enabled: true + test: + grails: + plugins: + serverTiming: + enabled: true + production: + grails: + plugins: + serverTiming: + enabled: false diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index 4285860..faaa608 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -5,6 +5,7 @@ import groovy.transform.CompileStatic import grails.artefact.Interceptor import groovy.util.logging.Slf4j import org.grails.plugins.servertiming.core.TimingMetric +import org.springframework.beans.factory.annotation.Autowired /** * Interceptor that tracks timing for controller actions and view rendering. @@ -16,20 +17,20 @@ class ServerTimingInterceptor implements Interceptor { static String HEADER_NAME = 'Server-Timing' - String metricKey = ServerTimingUtils.instance.metricKey + @Autowired + ServerTimingProperties serverTimingProperties ServerTimingInterceptor() { - if (ServerTimingUtils.instance.enabled) { - log.debug("Server Timing metrics are enabled. Set 'grails.plugins.serverTiming.enabled' to false to disable them.") - matchAll() - } else { - log.debug("Server Timing metrics are disabled. Set 'grails.plugins.serverTiming.enabled' to true to enable them.") - } + matchAll() } @Override boolean before() { - def timing = request.getAttribute(metricKey) as TimingMetric + if (!serverTimingProperties.enabled) { + return true + } + + def timing = request.getAttribute(serverTimingProperties.metricKey) as TimingMetric if (timing) { timing.create('action', 'Action') .start() @@ -39,12 +40,16 @@ class ServerTimingInterceptor implements Interceptor { @Override boolean after() { + if (!serverTimingProperties.enabled) { + return true + } + if (response.committed) { // no view could be rendered return true } - def timing = request.getAttribute(metricKey) as TimingMetric + def timing = request.getAttribute(serverTimingProperties.metricKey) as TimingMetric if (timing) { timing.get('action')?.stop() timing.create('view', 'View') diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy index 5f07ab0..a9aa023 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy @@ -1,41 +1,48 @@ package org.grails.plugins.servertiming import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Conditional /** * Spring Boot auto-configuration for the Server Timing plugin. * - *

    Registers a {@link ServerTimingFilter} and its {@link FilterRegistrationBean} - * when the plugin is enabled and the application is a servlet-based web application.

    + *

    Unconditionally registers {@link ServerTimingProperties} so that all + * components (including Grails artefacts like {@link ServerTimingInterceptor}) + * can access plugin configuration.

    * - *

    Enablement is controlled by {@link ServerTimingEnabledCondition}, which delegates - * to {@link ServerTimingUtils#isEnabled()} — auto-enabled in {@code DEVELOPMENT} and - * {@code TEST} environments, disabled in {@code PRODUCTION}, unless explicitly - * overridden via {@code grails.plugins.serverTiming.enabled}.

    + *

    Conditionally registers a {@link ServerTimingFilter} and its + * {@link FilterRegistrationBean} when the plugin is enabled and the application + * is a servlet-based web application. When the plugin is disabled, no filter is + * registered, avoiding any request-processing overhead.

    + * + *

    Enablement is controlled by the {@code grails.plugins.serverTiming.enabled} + * property via {@code @ConditionalOnProperty}. Default values are set per Grails + * environment in {@code plugin.yml}: enabled in {@code development} and + * {@code test}, disabled in {@code production}.

    * * @see ServerTimingFilter - * @see ServerTimingEnabledCondition - * @see ServerTimingUtils + * @see ServerTimingProperties */ -@Slf4j @CompileStatic @AutoConfiguration -@Conditional(ServerTimingEnabledCondition) -@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@EnableConfigurationProperties(ServerTimingProperties) class ServerTimingAutoConfiguration { @Bean - ServerTimingFilter serverTimingFilter() { - new ServerTimingFilter() + @ConditionalOnProperty(name = 'grails.plugins.serverTiming.enabled', havingValue = 'true') + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + ServerTimingFilter serverTimingFilter(ServerTimingProperties properties) { + new ServerTimingFilter(properties) } @Bean + @ConditionalOnProperty(name = 'grails.plugins.serverTiming.enabled', havingValue = 'true') + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) FilterRegistrationBean serverTimingFilterRegistration(ServerTimingFilter serverTimingFilter) { def registration = new FilterRegistrationBean(serverTimingFilter) registration.urlPatterns = ['/*'] diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy deleted file mode 100644 index 5bb83c6..0000000 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingEnabledCondition.groovy +++ /dev/null @@ -1,35 +0,0 @@ -package org.grails.plugins.servertiming - -import grails.util.Environment -import groovy.transform.CompileStatic -import org.springframework.context.annotation.Condition -import org.springframework.context.annotation.ConditionContext -import org.springframework.core.type.AnnotatedTypeMetadata - -/** - * A Spring {@link Condition} that evaluates whether the Server Timing plugin is enabled. - * - *

    Checks the {@code grails.plugins.serverTiming.enabled} property from the Spring - * {@link org.springframework.core.env.Environment}. If the property is not set, defaults - * to enabled in {@code DEVELOPMENT} and {@code TEST} Grails environments and disabled - * in {@code PRODUCTION}.

    - * - *

    This condition reads directly from the Spring Environment rather than - * {@code Holders.config} because auto-configuration conditions are evaluated before - * the Grails config holder is initialized.

    - * - * @see ServerTimingAutoConfiguration - */ -@CompileStatic -class ServerTimingEnabledCondition implements Condition { - - @Override - boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - def explicitlyEnabled = context.environment.getProperty('grails.plugins.serverTiming.enabled', Boolean) - if (explicitlyEnabled != null) { - return explicitlyEnabled - } - - Environment.current in [Environment.DEVELOPMENT, Environment.TEST] - } -} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index c90f4dd..c6f65b4 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy @@ -16,20 +16,30 @@ import org.springframework.core.Ordered /** * A Servlet Filter that wraps responses to ensure Server Timing headers are added to HTTP responses. * - * This filter works in conjunction with the TimingMetricInterceptor & ServerTimingResponseWrapper. - * The interceptor assists in creating initial timing metrics for actions & views - * The response wrapper ensures the Server Timing header is added before the response is committed. - * For non-controller requests (static resources, etc.), the filter tracks timing as 'other'. + *

    This filter is only registered when the plugin is enabled, as determined by + * {@code @ConditionalOnProperty} in {@link ServerTimingAutoConfiguration}. When + * disabled, no filter is registered and there is zero request-processing overhead.

    + * + *

    Works in conjunction with {@link ServerTimingInterceptor} and + * {@link ServerTimingResponseWrapper}. The interceptor creates timing metrics for + * controller actions and views. The response wrapper injects the {@code Server-Timing} + * header before the response is committed. For non-controller requests (static + * resources, etc.), the filter tracks timing as {@code other}.

    + * + * @see ServerTimingAutoConfiguration */ @Slf4j @CompileStatic class ServerTimingFilter implements Filter, Ordered { - private String metricKey + private final ServerTimingProperties properties + + ServerTimingFilter(ServerTimingProperties properties) { + this.properties = properties + } @Override void init(FilterConfig filterConfig) throws ServletException { - metricKey = ServerTimingUtils.instance.metricKey } @Override @@ -49,7 +59,7 @@ class ServerTimingFilter implements Filter, Ordered { // The interceptor will add 'action' and 'view' metrics for controller requests // For non-controller requests (static resources), we track as 'other' def timing = new TimingMetric() - httpRequest.setAttribute(metricKey, timing) + httpRequest.setAttribute(properties.metricKey, timing) timing.create('total', 'Total').start() timing.create('other', 'Non-Grails Controller Action/View').start() @@ -75,4 +85,3 @@ class ServerTimingFilter implements Filter, Ordered { Ordered.HIGHEST_PRECEDENCE + 100 } } - diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 5a99131..f82069c 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -10,11 +10,11 @@ import groovy.util.logging.Slf4j * for the Grails plugin framework.

    * *

    Bean wiring is handled by {@link ServerTimingAutoConfiguration} using standard - * Spring Boot auto-configuration rather than the Grails {@code doWithSpring()} mechanism.

    + * Spring Boot auto-configuration rather than the Grails {@code doWithSpring()} mechanism. + * Configuration properties are managed by {@link ServerTimingProperties}.

    * * @see ServerTimingAutoConfiguration - * @see ServerTimingFilter - * @see ServerTimingUtils + * @see ServerTimingProperties */ @Slf4j class ServerTimingGrailsPlugin extends Plugin { @@ -47,7 +47,8 @@ class ServerTimingGrailsPlugin extends Plugin { */ @Override void doWithApplicationContext() { - if (ServerTimingUtils.instance.enabled) { + def properties = applicationContext.getBean(ServerTimingProperties) + if (properties.enabled) { log.debug('Applying {} plugin', title) } else { log.debug('{} plugin is disabled. Set \'grails.plugins.serverTiming.enabled\' to true to enable it.', title) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy new file mode 100644 index 0000000..e03eeb9 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy @@ -0,0 +1,40 @@ +package org.grails.plugins.servertiming + +import groovy.transform.CompileStatic +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Configuration properties for the Server Timing plugin. + * + *

    Bound from the {@code grails.plugins.serverTiming} prefix in application + * or plugin configuration (e.g., {@code application.yml}, {@code plugin.yml}).

    + * + *

    The {@code enabled} property controls whether the plugin's filter and + * interceptor are active. Default values are set per Grails environment in + * {@code plugin.yml}: enabled in {@code development} and {@code test}, + * disabled in {@code production}.

    + * + *

    The {@code metricKey} property specifies the request attribute key used + * to store the {@link org.grails.plugins.servertiming.core.TimingMetric} + * instance on each request. Defaults to {@code GrailsServerTiming}.

    + * + * @see ServerTimingAutoConfiguration + * @see ServerTimingFilter + * @see ServerTimingInterceptor + */ +@CompileStatic +@ConfigurationProperties(prefix = 'grails.plugins.server-timing') +class ServerTimingProperties { + + /** + * Whether the Server Timing plugin is enabled. + * Defaults to {@code true} in development and test environments, + * {@code false} in production (configured via {@code plugin.yml}). + */ + boolean enabled = false + + /** + * The request attribute key used to store timing metrics on each request. + */ + String metricKey = 'GrailsServerTiming' +} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy deleted file mode 100644 index 8126af7..0000000 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy +++ /dev/null @@ -1,26 +0,0 @@ -package org.grails.plugins.servertiming - -import grails.util.Environment -import grails.util.Holders -import groovy.transform.CompileStatic - -/** - * Various utilities for configuring the Server Timing plugin - */ -@CompileStatic -@Singleton -class ServerTimingUtils { - - boolean isEnabled() { - Boolean explicitlyEnabled = Holders.config.getProperty('grails.plugins.serverTiming.enabled', Boolean, null) - if (explicitlyEnabled != null) { - return explicitlyEnabled - } - - return Environment.current in [Environment.DEVELOPMENT, Environment.TEST] - } - - String getMetricKey() { - Holders.config.getProperty('grails.plugins.serverTiming.metricKey', String, 'GrailsServerTiming') - } -} From c0eb2d0a9d7e6bc783e7ed06aa2c3f19a3e38563 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 18 Feb 2026 12:35:58 -0500 Subject: [PATCH 20/47] revert feedback: remove autoconfiguration & properties until https://github.com/apache/grails-core/issues/15408 is fixed --- .skills/plugin-project.md | 21 +++++-- AGENTS.md | 37 +++++------- docs/src/docs/how-it-works.adoc | 12 ++-- plugin/grails-app/conf/plugin.yml | 21 ------- .../ServerTimingInterceptor.groovy | 23 +++----- .../ServerTimingAutoConfiguration.groovy | 53 ----------------- .../servertiming/ServerTimingFilter.groovy | 24 +++----- .../ServerTimingGrailsPlugin.groovy | 57 +++++++++++++++---- .../ServerTimingProperties.groovy | 40 ------------- .../servertiming/ServerTimingUtils.groovy | 26 +++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 - 11 files changed, 123 insertions(+), 192 deletions(-) delete mode 100644 plugin/grails-app/conf/plugin.yml delete mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy delete mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy delete mode 100644 plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/.skills/plugin-project.md b/.skills/plugin-project.md index c8d52bb..f5f276f 100644 --- a/.skills/plugin-project.md +++ b/.skills/plugin-project.md @@ -50,10 +50,10 @@ plugin/ └── src/ ├── main/groovy/ # Core plugin classes │ └── org/grails/plugins/servertiming/ - │ ├── ServerTimingAutoConfiguration.groovy + │ ├── GrailsServerTimingGrailsPlugin.groovy │ ├── ServerTimingFilter.groovy - │ ├── ServerTimingProperties.groovy │ ├── ServerTimingResponseWrapper.groovy + │ ├── ServerTimingUtils.groovy │ └── core/ │ ├── Metric.groovy │ └── TimingMetric.groovy @@ -103,6 +103,7 @@ Key patterns: Unit tests in the plugin project test individual classes in isolation: - Test domain logic, validation, and data structures (e.g., `Metric`, `TimingMetric`) +- Test utility classes (e.g., `ServerTimingUtils`) - Use Spock Framework with `@Unroll` for data-driven tests - Do NOT start the Grails application context for unit tests - Do NOT make HTTP requests in unit tests @@ -126,10 +127,18 @@ Unit tests in the plugin project test individual classes in isolation: ## Plugin Descriptor -The `ServerTimingGrailsPlugin` class extends `grails.plugins.Plugin` and provides plugin metadata. -Bean wiring is handled by `ServerTimingAutoConfiguration` using Spring Boot auto-configuration. -Configuration is managed by `ServerTimingProperties` (`@ConfigurationProperties`), with -environment-based defaults set in `plugin.yml`. +The `GrailsServerTimingGrailsPlugin` class extends `grails.plugins.Plugin` and registers Spring beans. It uses +`ServerTimingUtils` to check whether the plugin is enabled before registering the filter: + +```groovy +Closure doWithSpring() { + { -> + if (ServerTimingUtils.instance.isEnabled(grailsApplication)) { + // register filter beans + } + } +} +``` ## Dependency Scoping diff --git a/AGENTS.md b/AGENTS.md index 6455d47..9f8b90b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,32 +104,27 @@ Run `sdk env install` to set up the environment. ## Architecture -The plugin intercepts HTTP requests via a servlet filter and Grails interceptor. Bean wiring uses -Spring Boot `@AutoConfiguration` (not the Grails plugin descriptor's `doWithSpring()`): +The plugin intercepts HTTP requests via a servlet filter and Grails interceptor: -1. **`ServerTimingAutoConfiguration`** (Spring Boot auto-configuration) unconditionally registers - `ServerTimingProperties` and conditionally registers the `ServerTimingFilter` and its - `FilterRegistrationBean` when enabled (via `@ConditionalOnProperty`). -2. **`ServerTimingFilter`** (servlet filter, highest precedence + 100) wraps every request, starts `total` and `other` +1. **`ServerTimingFilter`** (servlet filter, highest precedence + 100) wraps every request, starts `total` and `other` timers. -3. **`ServerTimingInterceptor`** (Grails interceptor) starts an `action` timer in `before()`, stops it and starts a +2. **`ServerTimingInterceptor`** (Grails interceptor) starts an `action` timer in `before()`, stops it and starts a `view` timer in `after()`. -4. **`ServerTimingResponseWrapper`** intercepts response commit to inject the `Server-Timing` header before the first +3. **`ServerTimingResponseWrapper`** intercepts response commit to inject the `Server-Timing` header before the first byte is written. -5. For non-controller requests (e.g., static assets), only `total` and `other` metrics appear. +4. For non-controller requests (e.g., static assets), only `total` and `other` metrics appear. ### Core Classes (plugin/src/main/groovy/org/grails/plugins/servertiming/) -| Class | Purpose | -|----------------------------------|---------------------------------------------------------------------------| -| `ServerTimingAutoConfiguration` | Spring Boot `@AutoConfiguration`; registers properties and filter beans | -| `ServerTimingProperties` | `@ConfigurationProperties` for `grails.plugins.serverTiming.*` settings | -| `ServerTimingGrailsPlugin` | Plugin descriptor; metadata only (no bean wiring) | -| `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | -| `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | -| `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | -| `core/Metric` | Single timing metric model with RFC 7230 name validation | -| `core/TimingMetric` | Collection of metrics; generates header value | +| Class | Purpose | +|----------------------------------|--------------------------------------------------------------------| +| `GrailsServerTimingGrailsPlugin` | Plugin descriptor; registers the filter bean when enabled | +| `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | +| `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | +| `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | +| `ServerTimingUtils` | Reads plugin configuration; auto-enables in DEV/TEST environments | +| `core/Metric` | Single timing metric model with RFC 7230 name validation | +| `core/TimingMetric` | Collection of metrics; generates header value | ## Configuration @@ -137,13 +132,11 @@ Set in `application.yml`: | Property | Default | Description | |-----------------------------------------|--------------------------------------------|-------------------------------------------| -| `grails.plugins.serverTiming.enabled` | `true` in DEV/TEST, `false` in PROD | Enable/disable the plugin | +| `grails.plugins.serverTiming.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | | `grails.plugins.serverTiming.metricKey` | `GrailsServerTiming` | Request attribute key for storing metrics | **Security note:** The plugin is disabled in production by default because timing data could facilitate timing attacks. -Default values per environment are set in `plugin.yml` using Grails environment-based configuration. - ## Testing ### Unit Tests (`plugin/src/test/`) diff --git a/docs/src/docs/how-it-works.adoc b/docs/src/docs/how-it-works.adoc index afbed8a..77f4501 100644 --- a/docs/src/docs/how-it-works.adoc +++ b/docs/src/docs/how-it-works.adoc @@ -4,15 +4,11 @@ This section explains the architecture of the plugin and how it handles differen === Architecture Overview -The plugin consists of four main components that work together: +The plugin consists of three main components that work together: -1. **ServerTimingAutoConfiguration** - A Spring Boot `@AutoConfiguration` that registers `ServerTimingProperties` and conditionally registers the filter and its `FilterRegistrationBean` when the plugin is enabled (via `@ConditionalOnProperty`) -2. **ServerTimingFilter** - A servlet filter that wraps all HTTP responses -3. **ServerTimingInterceptor** - A Grails interceptor that tracks controller action and view timing -4. **ServerTimingResponseWrapper** - A response wrapper that ensures headers are added before the response is committed - -Bean wiring uses standard Spring Boot auto-configuration rather than the Grails plugin descriptor's `doWithSpring()`. -The `ServerTimingGrailsPlugin` class provides plugin metadata only. +1. **ServerTimingFilter** - A servlet filter that wraps all HTTP responses +2. **ServerTimingInterceptor** - A Grails interceptor that tracks controller action and view timing +3. **ServerTimingResponseWrapper** - A response wrapper that ensures headers are added before the response is committed === Request Flow diff --git a/plugin/grails-app/conf/plugin.yml b/plugin/grails-app/conf/plugin.yml deleted file mode 100644 index 2e83c3e..0000000 --- a/plugin/grails-app/conf/plugin.yml +++ /dev/null @@ -1,21 +0,0 @@ -grails: - plugins: - serverTiming: - metricKey: GrailsServerTiming - -environments: - development: - grails: - plugins: - serverTiming: - enabled: true - test: - grails: - plugins: - serverTiming: - enabled: true - production: - grails: - plugins: - serverTiming: - enabled: false diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index faaa608..4285860 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -5,7 +5,6 @@ import groovy.transform.CompileStatic import grails.artefact.Interceptor import groovy.util.logging.Slf4j import org.grails.plugins.servertiming.core.TimingMetric -import org.springframework.beans.factory.annotation.Autowired /** * Interceptor that tracks timing for controller actions and view rendering. @@ -17,20 +16,20 @@ class ServerTimingInterceptor implements Interceptor { static String HEADER_NAME = 'Server-Timing' - @Autowired - ServerTimingProperties serverTimingProperties + String metricKey = ServerTimingUtils.instance.metricKey ServerTimingInterceptor() { - matchAll() + if (ServerTimingUtils.instance.enabled) { + log.debug("Server Timing metrics are enabled. Set 'grails.plugins.serverTiming.enabled' to false to disable them.") + matchAll() + } else { + log.debug("Server Timing metrics are disabled. Set 'grails.plugins.serverTiming.enabled' to true to enable them.") + } } @Override boolean before() { - if (!serverTimingProperties.enabled) { - return true - } - - def timing = request.getAttribute(serverTimingProperties.metricKey) as TimingMetric + def timing = request.getAttribute(metricKey) as TimingMetric if (timing) { timing.create('action', 'Action') .start() @@ -40,16 +39,12 @@ class ServerTimingInterceptor implements Interceptor { @Override boolean after() { - if (!serverTimingProperties.enabled) { - return true - } - if (response.committed) { // no view could be rendered return true } - def timing = request.getAttribute(serverTimingProperties.metricKey) as TimingMetric + def timing = request.getAttribute(metricKey) as TimingMetric if (timing) { timing.get('action')?.stop() timing.create('view', 'View') diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy deleted file mode 100644 index a9aa023..0000000 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy +++ /dev/null @@ -1,53 +0,0 @@ -package org.grails.plugins.servertiming - -import groovy.transform.CompileStatic -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.web.servlet.FilterRegistrationBean -import org.springframework.context.annotation.Bean - -/** - * Spring Boot auto-configuration for the Server Timing plugin. - * - *

    Unconditionally registers {@link ServerTimingProperties} so that all - * components (including Grails artefacts like {@link ServerTimingInterceptor}) - * can access plugin configuration.

    - * - *

    Conditionally registers a {@link ServerTimingFilter} and its - * {@link FilterRegistrationBean} when the plugin is enabled and the application - * is a servlet-based web application. When the plugin is disabled, no filter is - * registered, avoiding any request-processing overhead.

    - * - *

    Enablement is controlled by the {@code grails.plugins.serverTiming.enabled} - * property via {@code @ConditionalOnProperty}. Default values are set per Grails - * environment in {@code plugin.yml}: enabled in {@code development} and - * {@code test}, disabled in {@code production}.

    - * - * @see ServerTimingFilter - * @see ServerTimingProperties - */ -@CompileStatic -@AutoConfiguration -@EnableConfigurationProperties(ServerTimingProperties) -class ServerTimingAutoConfiguration { - - @Bean - @ConditionalOnProperty(name = 'grails.plugins.serverTiming.enabled', havingValue = 'true') - @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) - ServerTimingFilter serverTimingFilter(ServerTimingProperties properties) { - new ServerTimingFilter(properties) - } - - @Bean - @ConditionalOnProperty(name = 'grails.plugins.serverTiming.enabled', havingValue = 'true') - @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) - FilterRegistrationBean serverTimingFilterRegistration(ServerTimingFilter serverTimingFilter) { - def registration = new FilterRegistrationBean(serverTimingFilter) - registration.urlPatterns = ['/*'] - registration.order = serverTimingFilter.order - registration.name = 'serverTimingFilter' - registration - } -} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index c6f65b4..e1e1381 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy @@ -16,30 +16,20 @@ import org.springframework.core.Ordered /** * A Servlet Filter that wraps responses to ensure Server Timing headers are added to HTTP responses. * - *

    This filter is only registered when the plugin is enabled, as determined by - * {@code @ConditionalOnProperty} in {@link ServerTimingAutoConfiguration}. When - * disabled, no filter is registered and there is zero request-processing overhead.

    - * - *

    Works in conjunction with {@link ServerTimingInterceptor} and - * {@link ServerTimingResponseWrapper}. The interceptor creates timing metrics for - * controller actions and views. The response wrapper injects the {@code Server-Timing} - * header before the response is committed. For non-controller requests (static - * resources, etc.), the filter tracks timing as {@code other}.

    - * - * @see ServerTimingAutoConfiguration + * This filter works in conjunction with the TimingMetricInterceptor & ServerTimingResponseWrapper. + * The interceptor assists in creating initial timing metrics for actions & views + * The response wrapper ensures the Server Timing header is added before the response is committed. + * For non-controller requests (static resources, etc.), the filter tracks timing as 'other'. */ @Slf4j @CompileStatic class ServerTimingFilter implements Filter, Ordered { - private final ServerTimingProperties properties - - ServerTimingFilter(ServerTimingProperties properties) { - this.properties = properties - } + private String metricKey @Override void init(FilterConfig filterConfig) throws ServletException { + metricKey = ServerTimingUtils.instance.metricKey } @Override @@ -59,7 +49,7 @@ class ServerTimingFilter implements Filter, Ordered { // The interceptor will add 'action' and 'view' metrics for controller requests // For non-controller requests (static resources), we track as 'other' def timing = new TimingMetric() - httpRequest.setAttribute(properties.metricKey, timing) + httpRequest.setAttribute(metricKey, timing) timing.create('total', 'Total').start() timing.create('other', 'Non-Grails Controller Action/View').start() diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index f82069c..13c1e68 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -2,19 +2,30 @@ package org.grails.plugins.servertiming import grails.plugins.Plugin import groovy.util.logging.Slf4j +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.core.Ordered /** - * Grails plugin descriptor for the Server Timing plugin. + * Grails plugin that provides Server Timing header support for HTTP responses. * - *

    Provides plugin metadata (version compatibility, author, documentation, etc.) - * for the Grails plugin framework.

    + *

    This plugin automatically registers a {@link ServerTimingFilter} that adds + * Server Timing headers to HTTP responses, + * allowing developers to communicate backend server performance metrics to the browser.

    * - *

    Bean wiring is handled by {@link ServerTimingAutoConfiguration} using standard - * Spring Boot auto-configuration rather than the Grails {@code doWithSpring()} mechanism. - * Configuration properties are managed by {@link ServerTimingProperties}.

    + *

    Configuration

    + *

    The plugin can be enabled or disabled via the configuration property:

    + *
    + * grails.plugins.serverTiming.enabled = true
    + * 
    * - * @see ServerTimingAutoConfiguration - * @see ServerTimingProperties + *

    Filter Registration

    + *

    When enabled, the plugin registers a servlet filter with the following characteristics:

    + *
      + *
    • URL Pattern: /* (applies to all requests)
    • + *
    • Order: Ordered.HIGHEST_PRECEDENCE + 100 (executes early in the filter chain)
    • + *
    + * + * @see ServerTimingFilter* @see ServerTimingUtils */ @Slf4j class ServerTimingGrailsPlugin extends Plugin { @@ -40,6 +51,33 @@ class ServerTimingGrailsPlugin extends Plugin { /** Source control management information */ def scm = [url: 'https://github.com/grails-plugins/grails-server-timing'] + /** + * Registers Spring beans for the Server Timing functionality. + * + *

    When the plugin is enabled, this method registers:

    + *
      + *
    • serverTimingFilter - The {@link ServerTimingFilter} bean
    • + *
    • serverTimingFilterRegistration - A {@link FilterRegistrationBean} + * that configures the filter to intercept all requests
    • + *
    + * + * @return a closure that defines the Spring bean configuration + */ + Closure doWithSpring() { + { -> + if (ServerTimingUtils.instance.enabled) { + serverTimingFilter(ServerTimingFilter) + + serverTimingFilterRegistration(FilterRegistrationBean) { + filter = ref('serverTimingFilter') + urlPatterns = ['/*'] + order = Ordered.HIGHEST_PRECEDENCE + 100 + name = 'serverTimingFilter' + } + } + } + } + /** * Performs initialization tasks after the Spring application context is available. * @@ -47,8 +85,7 @@ class ServerTimingGrailsPlugin extends Plugin { */ @Override void doWithApplicationContext() { - def properties = applicationContext.getBean(ServerTimingProperties) - if (properties.enabled) { + if (ServerTimingUtils.instance.enabled) { log.debug('Applying {} plugin', title) } else { log.debug('{} plugin is disabled. Set \'grails.plugins.serverTiming.enabled\' to true to enable it.', title) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy deleted file mode 100644 index e03eeb9..0000000 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingProperties.groovy +++ /dev/null @@ -1,40 +0,0 @@ -package org.grails.plugins.servertiming - -import groovy.transform.CompileStatic -import org.springframework.boot.context.properties.ConfigurationProperties - -/** - * Configuration properties for the Server Timing plugin. - * - *

    Bound from the {@code grails.plugins.serverTiming} prefix in application - * or plugin configuration (e.g., {@code application.yml}, {@code plugin.yml}).

    - * - *

    The {@code enabled} property controls whether the plugin's filter and - * interceptor are active. Default values are set per Grails environment in - * {@code plugin.yml}: enabled in {@code development} and {@code test}, - * disabled in {@code production}.

    - * - *

    The {@code metricKey} property specifies the request attribute key used - * to store the {@link org.grails.plugins.servertiming.core.TimingMetric} - * instance on each request. Defaults to {@code GrailsServerTiming}.

    - * - * @see ServerTimingAutoConfiguration - * @see ServerTimingFilter - * @see ServerTimingInterceptor - */ -@CompileStatic -@ConfigurationProperties(prefix = 'grails.plugins.server-timing') -class ServerTimingProperties { - - /** - * Whether the Server Timing plugin is enabled. - * Defaults to {@code true} in development and test environments, - * {@code false} in production (configured via {@code plugin.yml}). - */ - boolean enabled = false - - /** - * The request attribute key used to store timing metrics on each request. - */ - String metricKey = 'GrailsServerTiming' -} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy new file mode 100644 index 0000000..8126af7 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy @@ -0,0 +1,26 @@ +package org.grails.plugins.servertiming + +import grails.util.Environment +import grails.util.Holders +import groovy.transform.CompileStatic + +/** + * Various utilities for configuring the Server Timing plugin + */ +@CompileStatic +@Singleton +class ServerTimingUtils { + + boolean isEnabled() { + Boolean explicitlyEnabled = Holders.config.getProperty('grails.plugins.serverTiming.enabled', Boolean, null) + if (explicitlyEnabled != null) { + return explicitlyEnabled + } + + return Environment.current in [Environment.DEVELOPMENT, Environment.TEST] + } + + String getMetricKey() { + Holders.config.getProperty('grails.plugins.serverTiming.metricKey', String, 'GrailsServerTiming') + } +} diff --git a/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 8fadc0c..0000000 --- a/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -org.grails.plugins.servertiming.ServerTimingAutoConfiguration From 575372c3f26e6fa5619fc27fb8c93792b09bc5df Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 18 Feb 2026 12:38:14 -0500 Subject: [PATCH 21/47] feedback: handle distribution between release & ci the same --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 786ee2d..957189e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: - 'main' pull_request: workflow_dispatch: +env: + JAVA_DISTRIBUTION: 'liberica' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -28,7 +30,7 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - distribution: liberica + distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: 'Ensure Common Build Date' # to ensure a reproducible build run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" @@ -70,7 +72,7 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - distribution: liberica + distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: 'Ensure Common Build Date' # to ensure a reproducible build run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" From 6b4fe33ebb316c37e37bc4ea3bda9e246b196a7b Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 18 Feb 2026 12:41:06 -0500 Subject: [PATCH 22/47] feedback: use gradle properties for versions --- .skills/gradle-best-practices.md | 2 +- build-logic/build.gradle | 5 +++-- docs/build.gradle | 2 +- gradle.properties | 2 ++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.skills/gradle-best-practices.md b/.skills/gradle-best-practices.md index 6cb2058..392bd10 100644 --- a/.skills/gradle-best-practices.md +++ b/.skills/gradle-best-practices.md @@ -90,7 +90,7 @@ plugins { dependencies { implementation platform("org.apache.grails:grails-bom:${gradleProperties.grailsVersion}") implementation 'org.apache.grails:grails-gradle-plugins' - implementation 'com.adarshr:gradle-test-logger-plugin:4.0.0' + implementation "com.adarshr:gradle-test-logger-plugin:${gradleProperties.testLoggerVersion}" implementation 'cloud.wondrify:asset-pipeline-gradle' implementation 'org.apache.grails.gradle:grails-publish' } diff --git a/build-logic/build.gradle b/build-logic/build.gradle index 6022f1b..b227d94 100644 --- a/build-logic/build.gradle +++ b/build-logic/build.gradle @@ -17,7 +17,8 @@ allprojects { dependencies { implementation platform("org.apache.grails:grails-bom:${gradleProperties.grailsVersion}") implementation 'org.apache.grails:grails-gradle-plugins' - implementation 'com.adarshr:gradle-test-logger-plugin:4.0.0' + implementation "com.adarshr:gradle-test-logger-plugin:${gradleProperties.testLoggerVersion}" + implementation "org.asciidoctor.jvm.convert:org.asciidoctor.jvm.convert.gradle.plugin:${gradleProperties.asciidoctorVersion}" implementation 'cloud.wondrify:asset-pipeline-gradle' implementation 'org.apache.grails.gradle:grails-publish' -} \ No newline at end of file +} diff --git a/docs/build.gradle b/docs/build.gradle index 659fa5b..e14902f 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -1,7 +1,7 @@ import org.asciidoctor.gradle.jvm.AsciidoctorTask plugins { - id "org.asciidoctor.jvm.convert" version "4.0.5" + id 'org.asciidoctor.jvm.convert' } version = projectVersion diff --git a/gradle.properties b/gradle.properties index 2331a26..7681403 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,6 +2,8 @@ projectVersion=0.0.1-SNAPSHOT grailsVersion=7.0.7 checkstyleVersion=10.21.4 codenarcVersion=3.6.0 +testLoggerVersion=4.0.0 +asciidoctorVersion=4.0.5 org.gradle.caching=true org.gradle.daemon=true From 9eb0171f1d0d22e0e075d37d581a46eb53457de3 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 18 Feb 2026 12:52:15 -0500 Subject: [PATCH 23/47] feedback: getWriter() and getOutputstream() need to function similarly --- docs/src/docs/how-it-works.adoc | 36 +- .../ServerTimingResponseWrapper.groovy | 59 ++- .../ServerTimingResponseWrapperSpec.groovy | 390 ++++++++++++++++++ 3 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 plugin/src/test/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapperSpec.groovy diff --git a/docs/src/docs/how-it-works.adoc b/docs/src/docs/how-it-works.adoc index 77f4501..564399c 100644 --- a/docs/src/docs/how-it-works.adoc +++ b/docs/src/docs/how-it-works.adoc @@ -74,14 +74,38 @@ For requests that do not hit a Grails controller (static assets, images, CSS, Ja A key technical challenge with Server Timing is that HTTP headers must be sent *before* the response body. However, we do not know the final timing values until *after* the view has rendered. -The `ServerTimingResponseWrapper` solves this by: +The `ServerTimingResponseWrapper` solves this by wrapping the original `HttpServletResponse` and deferring the +`Server-Timing` header injection until the last possible moment -- just before the first byte of body content is +written. This maximizes timing accuracy because all metrics (action, view, etc.) have as much time as possible to +accumulate before the header value is computed and frozen. -1. Wrapping the original `HttpServletResponse` -2. Intercepting calls to `getOutputStream()`, `getWriter()`, `flushBuffer()`, etc. -3. Adding the `Server-Timing` header just before the first byte is written -4. Delegating all other operations to the original response +==== Deferred Header Injection -This ensures the header is always present, regardless of how the response is generated. +Both output paths use the same deferred strategy: + +* **`getOutputStream()`** returns a `ServerTimingServletOutputStream` that intercepts `write()`, `flush()`, and +`close()`. On the first call, it triggers header injection before delegating to the underlying stream. +* **`getWriter()`** returns a `ServerTimingPrintWriter` that intercepts `write()`, `flush()`, and `close()` in the +same way. All higher-level `PrintWriter` methods (`print()`, `println()`, `printf()`, etc.) route through the +overridden `write()` methods, so they are also covered. + +In both cases, subsequent writes pass through directly without any overhead. + +==== Other Commit Points + +The wrapper also intercepts methods that commit the response without writing body content: + +* `sendError()` -- error responses +* `sendRedirect()` -- redirect responses +* `flushBuffer()` -- explicit buffer flushes + +These inject the header eagerly since the response is being committed immediately. + +==== Safety Net + +As a final safeguard, the `ServerTimingFilter` calls `beforeCommit()` in a `finally` block after the filter chain +completes. This handles edge cases where no output was written (e.g., 204 No Content responses). The header +injection is idempotent -- a boolean flag ensures it only runs once regardless of how many commit points are hit. === Metric Descriptions diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy index 0651276..74f1546 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy @@ -85,8 +85,10 @@ class ServerTimingResponseWrapper extends HttpServletResponseWrapper { @Override PrintWriter getWriter() throws IOException { if (wrappedWriter == null) { - addServerTimingHeaderIfNeeded() - wrappedWriter = originalResponse.getWriter() + wrappedWriter = new ServerTimingPrintWriter( + originalResponse.getWriter(), + this + ) } return wrappedWriter } @@ -190,5 +192,58 @@ class ServerTimingResponseWrapper extends HttpServletResponseWrapper { delegate.setWriteListener(writeListener) } } + + /** + * Wrapped PrintWriter that triggers header addition before first write, + * consistent with the deferred approach used by ServerTimingServletOutputStream. + */ + @CompileStatic + private static class ServerTimingPrintWriter extends PrintWriter { + + private final ServerTimingResponseWrapper wrapper + private boolean firstWrite = true + + ServerTimingPrintWriter(PrintWriter delegate, ServerTimingResponseWrapper wrapper) { + super(delegate) + this.wrapper = wrapper + } + + private void beforeWrite() { + if (firstWrite) { + firstWrite = false + wrapper.beforeCommit() + } + } + + @Override + void write(int c) { + beforeWrite() + super.write(c) + } + + @Override + void write(char[] buf, int off, int len) { + beforeWrite() + super.write(buf, off, len) + } + + @Override + void write(String s, int off, int len) { + beforeWrite() + super.write(s, off, len) + } + + @Override + void flush() { + beforeWrite() + super.flush() + } + + @Override + void close() { + beforeWrite() + super.close() + } + } } diff --git a/plugin/src/test/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapperSpec.groovy b/plugin/src/test/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapperSpec.groovy new file mode 100644 index 0000000..a179f92 --- /dev/null +++ b/plugin/src/test/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapperSpec.groovy @@ -0,0 +1,390 @@ +package org.grails.plugins.servertiming + +import jakarta.servlet.ServletOutputStream +import jakarta.servlet.WriteListener +import jakarta.servlet.http.HttpServletResponse +import org.grails.plugins.servertiming.core.TimingMetric +import spock.lang.Specification + +class ServerTimingResponseWrapperSpec extends Specification { + + HttpServletResponse mockResponse + TimingMetric timing + + def setup() { + mockResponse = Mock(HttpServletResponse) + timing = new TimingMetric() + timing.create('total', 'Total').start() + } + + // --- getOutputStream() tests --- + + def "getOutputStream() does not add header until first write"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: 'getting the output stream without writing' + wrapper.getOutputStream() + + then: 'header is not added yet' + 0 * mockResponse.addHeader(_, _) + } + + def "getOutputStream() adds header on first write(int)"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + + when: + os.write(65) + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getOutputStream() adds header on first write(byte[])"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + + when: + os.write('hello'.bytes) + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getOutputStream() adds header on first write(byte[], off, len)"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + def bytes = 'hello'.bytes + + when: + os.write(bytes, 0, bytes.length) + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getOutputStream() adds header on flush"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + + when: + os.flush() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getOutputStream() adds header on close"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + + when: + os.close() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getOutputStream() adds header only once across multiple writes"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + + when: + os.write(65) + os.write(66) + os.write(67) + os.flush() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + // --- getWriter() tests --- + + def "getWriter() does not add header until first write"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: 'getting the writer without writing' + wrapper.getWriter() + + then: 'header is not added yet' + 0 * mockResponse.addHeader(_, _) + } + + def "getWriter() adds header on first write(int)"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + + when: + writer.write((int) 'A'.charAt(0)) + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getWriter() adds header on first write(char[], off, len)"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + def chars = 'hello'.toCharArray() + + when: + writer.write(chars, 0, chars.length) + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getWriter() adds header on first write(String, off, len)"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + + when: + writer.write('hello', 0, 5) + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getWriter() adds header on flush"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + + when: + writer.flush() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getWriter() adds header on close"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + + when: + writer.close() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "getWriter() adds header only once across multiple writes"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + + when: + writer.write('hello') + writer.write(' world') + writer.flush() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + // --- Behavioral consistency tests --- + + def "getWriter() and getOutputStream() both defer header injection to first write"() { + given: 'a wrapper using getOutputStream' + def realOutputStream = new StubServletOutputStream() + def osResponse = Mock(HttpServletResponse) + osResponse.getOutputStream() >> realOutputStream + def osTiming = new TimingMetric() + osTiming.create('total', 'Total').start() + def osWrapper = new ServerTimingResponseWrapper(osResponse, osTiming) + + and: 'a wrapper using getWriter' + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + def writerResponse = Mock(HttpServletResponse) + writerResponse.getWriter() >> realWriter + def writerTiming = new TimingMetric() + writerTiming.create('total', 'Total').start() + def writerWrapper = new ServerTimingResponseWrapper(writerResponse, writerTiming) + + when: 'both are obtained but not yet written to' + osWrapper.getOutputStream() + writerWrapper.getWriter() + + then: 'neither adds the header' + 0 * osResponse.addHeader(_, _) + 0 * writerResponse.addHeader(_, _) + + when: 'both write data' + osWrapper.getOutputStream().write(65) + writerWrapper.getWriter().write('A') + + then: 'both add the header exactly once' + 1 * osResponse.addHeader('Server-Timing', _) + 1 * writerResponse.addHeader('Server-Timing', _) + } + + def "getWriter() data is written through to delegate"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def writer = wrapper.getWriter() + + when: + writer.write('hello world') + writer.flush() + + then: + stringWriter.toString() == 'hello world' + } + + def "getOutputStream() data is written through to delegate"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + def os = wrapper.getOutputStream() + + when: + os.write('hello'.bytes) + + then: + realOutputStream.data.toByteArray() == 'hello'.bytes + } + + // --- beforeCommit / safety net tests --- + + def "beforeCommit() adds header even if neither getWriter nor getOutputStream was called"() { + given: + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: + wrapper.beforeCommit() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "beforeCommit() is idempotent"() { + given: + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: + wrapper.beforeCommit() + wrapper.beforeCommit() + + then: + 1 * mockResponse.addHeader('Server-Timing', _) + } + + def "reset() allows header to be re-added"() { + given: + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: + wrapper.beforeCommit() + wrapper.reset() + wrapper.beforeCommit() + + then: + 2 * mockResponse.addHeader('Server-Timing', _) + } + + // --- Returns same instance tests --- + + def "getOutputStream() returns the same instance on subsequent calls"() { + given: + def realOutputStream = new StubServletOutputStream() + mockResponse.getOutputStream() >> realOutputStream + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: + def os1 = wrapper.getOutputStream() + def os2 = wrapper.getOutputStream() + + then: + os1.is(os2) + } + + def "getWriter() returns the same instance on subsequent calls"() { + given: + def stringWriter = new StringWriter() + def realWriter = new PrintWriter(stringWriter) + mockResponse.getWriter() >> realWriter + def wrapper = new ServerTimingResponseWrapper(mockResponse, timing) + + when: + def w1 = wrapper.getWriter() + def w2 = wrapper.getWriter() + + then: + w1.is(w2) + } + + /** + * Minimal ServletOutputStream stub for testing. + */ + static class StubServletOutputStream extends ServletOutputStream { + + final ByteArrayOutputStream data = new ByteArrayOutputStream() + + @Override + void write(int b) throws IOException { + data.write(b) + } + + @Override + boolean isReady() { + return true + } + + @Override + void setWriteListener(WriteListener writeListener) { + // no-op + } + } +} From 90237a808d3dbf88745cd9cd738bc670ac696512 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 18 Feb 2026 12:53:50 -0500 Subject: [PATCH 24/47] feedback: fix gradle wrapper updates --- .github/dependabot.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1bd1c37..693cc5b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - # Gradle dependencies (root + all subprojects) + # Gradle dependencies and wrapper - package-ecosystem: "gradle" directory: "/" schedule: @@ -13,15 +13,8 @@ updates: gradle-dependencies: patterns: - "*" - - # Gradle wrapper - - package-ecosystem: "gradle" - directory: "/gradle/wrapper" - schedule: - interval: "weekly" - day: "monday" - labels: - - "deps" + exclude-patterns: + - "gradle" ignore: - dependency-name: "gradle" versions: [ ">= 9" ] From 1d639a8a874fd1d99ce97abd8dc6bb7e310b434b Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sat, 21 Feb 2026 23:44:17 -0500 Subject: [PATCH 25/47] feedback move HEADER_NAME to ResponseWrapper --- .../plugins/servertiming/ServerTimingInterceptor.groovy | 2 -- .../servertiming/ServerTimingResponseWrapper.groovy | 9 +++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index 4285860..a5e3dc7 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -14,8 +14,6 @@ import org.grails.plugins.servertiming.core.TimingMetric @CompileStatic class ServerTimingInterceptor implements Interceptor { - static String HEADER_NAME = 'Server-Timing' - String metricKey = ServerTimingUtils.instance.metricKey ServerTimingInterceptor() { diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy index 74f1546..ee9dcfa 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy @@ -16,6 +16,7 @@ import org.grails.plugins.servertiming.core.TimingMetric @CompileStatic class ServerTimingResponseWrapper extends HttpServletResponseWrapper { + static String HEADER_NAME = 'Server-Timing' private final TimingMetric timing private final HttpServletResponse originalResponse private boolean headerAdded = false @@ -33,19 +34,19 @@ class ServerTimingResponseWrapper extends HttpServletResponseWrapper { */ private void addServerTimingHeaderIfNeeded() { if (!headerAdded && timing) { - log.debug('Adding {} header with timing metrics', ServerTimingInterceptor.HEADER_NAME) + log.debug('Adding {} header with timing metrics', HEADER_NAME) headerAdded = true stopTimings() def headerValue = timing.toHeaderValue() - log.trace('{} header value: {}', ServerTimingInterceptor.HEADER_NAME, headerValue) + log.trace('{} header value: {}', HEADER_NAME, headerValue) if (headerValue) { - originalResponse.addHeader(ServerTimingInterceptor.HEADER_NAME, headerValue) + originalResponse.addHeader(HEADER_NAME, headerValue) } } else { - log.debug('{} header already added or timing metric not available, skipping header addition', ServerTimingInterceptor.HEADER_NAME) + log.debug('{} header already added or timing metric not available, skipping header addition', HEADER_NAME) } } From 5d1498bfb420be804f992718004751cc8f541fc7 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sat, 21 Feb 2026 23:45:06 -0500 Subject: [PATCH 26/47] feedback: error on start if already started --- .../groovy/org/grails/plugins/servertiming/core/Metric.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy index 0bef824..6b8ae19 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy @@ -49,7 +49,7 @@ class Metric implements Validateable, Serializable { } Metric start() { - if (duration) { + if (startTimeNanos || duration) { throw new IllegalStateException('The metric has already started.') } From bcf7fbea6dcea38601e382fe5906e19df66694c4 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sat, 21 Feb 2026 23:45:44 -0500 Subject: [PATCH 27/47] feedback: fix javadoc formatting --- .../plugins/servertiming/ServerTimingGrailsPlugin.groovy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 13c1e68..30a02cb 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -25,7 +25,8 @@ import org.springframework.core.Ordered *
  • Order: Ordered.HIGHEST_PRECEDENCE + 100 (executes early in the filter chain)
  • *
* - * @see ServerTimingFilter* @see ServerTimingUtils + * @see ServerTimingFilter + * @see ServerTimingUtils */ @Slf4j class ServerTimingGrailsPlugin extends Plugin { From 46db640cddf9e5bbebf1300c4b9019a00d076ccf Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 23 Feb 2026 15:27:14 +0100 Subject: [PATCH 28/47] refactor: use auto-configuration and config properties --- AGENTS.md | 8 +-- docs/src/docs/configuration.adoc | 6 +-- ...ServerTimingDisabledIntegrationSpec.groovy | 2 +- .../ServerTimingInterceptor.groovy | 20 ++++--- .../ServerTimingAutoConfiguration.groovy | 54 +++++++++++++++++++ .../servertiming/ServerTimingFilter.groovy | 12 ++++- .../ServerTimingGrailsPlugin.groovy | 53 +++--------------- .../servertiming/ServerTimingUtils.groovy | 26 --------- .../config/EnabledCondition.groovy | 25 +++++++++ .../config/ServerTimingConfig.groovy | 14 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 11 files changed, 132 insertions(+), 89 deletions(-) create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy delete mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/config/EnabledCondition.groovy create mode 100644 plugin/src/main/groovy/org/grails/plugins/servertiming/config/ServerTimingConfig.groovy create mode 100644 plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/AGENTS.md b/AGENTS.md index 9f8b90b..92e3eef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,10 +130,10 @@ The plugin intercepts HTTP requests via a servlet filter and Grails interceptor: Set in `application.yml`: -| Property | Default | Description | -|-----------------------------------------|--------------------------------------------|-------------------------------------------| -| `grails.plugins.serverTiming.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | -| `grails.plugins.serverTiming.metricKey` | `GrailsServerTiming` | Request attribute key for storing metrics | +| Property | Default | Description | +|-------------------------------------------|--------------------------------------------|-------------------------------------------| +| `grails.plugins.server-timing.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | +| `grails.plugins.server-timing.metric-key` | `GrailsServerTiming` | Request attribute key for storing metrics | **Security note:** The plugin is disabled in production by default because timing data could facilitate timing attacks. diff --git a/docs/src/docs/configuration.adoc b/docs/src/docs/configuration.adoc index a124e87..06a2342 100644 --- a/docs/src/docs/configuration.adoc +++ b/docs/src/docs/configuration.adoc @@ -9,7 +9,7 @@ This ensures that performance timing information is available during development === Configuration Options -All configuration options are specified in your `application.yml` or `application.groovy` file under the `grails.plugins.servertiming` namespace. +All configuration options are specified in your `application.yml` or `application.groovy` file under the `grails.plugins.server-timing` namespace. ==== Enabling/Disabling the Plugin @@ -24,7 +24,7 @@ grails: |=== | Property | Type | Default | Description -| `grails.plugins.serverTiming.enabled` +| `grails.plugins.server-timing.enabled` | `Boolean` | `null` (auto-detect) | When `null`, the plugin is enabled in `development` and `test` environments only. Set to `true` to explicitly enable or `false` to explicitly disable regardless of environment. @@ -46,7 +46,7 @@ grails: |=== | Property | Type | Default | Description -| `grails.plugins.serverTiming.metricKey` +| `grails.plugins.server-timing.metric-key` | `String` | `GrailsServerTiming` | The request attribute key used to store the `TimingMetric` object. Only change this if you have a naming conflict. diff --git a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy index 0035ef6..b5c62de 100644 --- a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy +++ b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy @@ -10,7 +10,7 @@ import spock.lang.Specification /** * Integration tests verifying that the Server Timing HTTP header is NOT present * when the plugin is explicitly disabled via configuration - * (grails.plugins.serverTiming.enabled: false). + * (grails.plugins.server-timing.enabled: false). */ @Integration class ServerTimingDisabledIntegrationSpec extends Specification { diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index a5e3dc7..2f2c56b 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -1,9 +1,14 @@ package org.grails.plugins.servertiming import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.env.Environment import grails.artefact.Interceptor -import groovy.util.logging.Slf4j +import org.grails.plugins.servertiming.config.EnabledCondition +import org.grails.plugins.servertiming.config.ServerTimingConfig import org.grails.plugins.servertiming.core.TimingMetric /** @@ -14,15 +19,18 @@ import org.grails.plugins.servertiming.core.TimingMetric @CompileStatic class ServerTimingInterceptor implements Interceptor { - String metricKey = ServerTimingUtils.instance.metricKey + private String metricKey - ServerTimingInterceptor() { - if (ServerTimingUtils.instance.enabled) { - log.debug("Server Timing metrics are enabled. Set 'grails.plugins.serverTiming.enabled' to false to disable them.") + @Autowired + ServerTimingInterceptor(Environment env, ServerTimingConfig config) { + if (EnabledCondition.matches(env)) { + log.debug("Server Timing metrics are enabled. Set 'grails.plugins.server-timing.enabled' to false to disable them.") matchAll() } else { - log.debug("Server Timing metrics are disabled. Set 'grails.plugins.serverTiming.enabled' to true to enable them.") + log.debug("Server Timing metrics are disabled. Set 'grails.plugins.server-timing.enabled' to true to enable them.") } + + metricKey = config.metricKey } @Override diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy new file mode 100644 index 0000000..f85b663 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy @@ -0,0 +1,54 @@ +package org.grails.plugins.servertiming + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.ApplicationListener +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Conditional +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered + +import org.grails.plugins.servertiming.config.EnabledCondition +import org.grails.plugins.servertiming.config.ServerTimingConfig + +@Slf4j +@CompileStatic +@AutoConfiguration +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(ServerTimingConfig) +class ServerTimingAutoConfiguration { + + @Bean + @Conditional(EnabledCondition) + ServerTimingFilter serverTimingFilter() { + new ServerTimingFilter() + } + + @Bean + @Conditional(EnabledCondition) + FilterRegistrationBean serverTimingFilterRegistration(ServerTimingFilter serverTimingFilter) { + new FilterRegistrationBean().tap { + filter = serverTimingFilter + urlPatterns = ['/*'] + order = Ordered.HIGHEST_PRECEDENCE + 100 + name = 'serverTimingFilter' + } + } + + @Bean + ApplicationListener serverTimingAutoConfigLogger(ConfigurableApplicationContext context) { + { event -> + def applied = !context.getBeanProvider(ServerTimingFilter).stream().findAny().empty + def message = applied ? + 'Applying {} plugin' : + '{} plugin is disabled. Set \'grails.plugins.server-timing.enabled\' to true to enable it.' + log.debug(message, ServerTimingGrailsPlugin.pluginName) + } as ApplicationListener + } +} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index e1e1381..aa6908a 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy @@ -2,6 +2,7 @@ package org.grails.plugins.servertiming import groovy.transform.CompileStatic import groovy.util.logging.Slf4j + import jakarta.servlet.Filter import jakarta.servlet.FilterChain import jakarta.servlet.FilterConfig @@ -10,9 +11,13 @@ import jakarta.servlet.ServletRequest import jakarta.servlet.ServletResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.grails.plugins.servertiming.core.TimingMetric + +import org.springframework.beans.factory.annotation.Autowired import org.springframework.core.Ordered +import org.grails.plugins.servertiming.config.ServerTimingConfig +import org.grails.plugins.servertiming.core.TimingMetric + /** * A Servlet Filter that wraps responses to ensure Server Timing headers are added to HTTP responses. * @@ -25,11 +30,14 @@ import org.springframework.core.Ordered @CompileStatic class ServerTimingFilter implements Filter, Ordered { + @Autowired + ServerTimingConfig config + private String metricKey @Override void init(FilterConfig filterConfig) throws ServletException { - metricKey = ServerTimingUtils.instance.metricKey + metricKey = config.metricKey } @Override diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index 30a02cb..e5d2967 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -1,9 +1,8 @@ package org.grails.plugins.servertiming -import grails.plugins.Plugin import groovy.util.logging.Slf4j -import org.springframework.boot.web.servlet.FilterRegistrationBean -import org.springframework.core.Ordered + +import grails.plugins.Plugin /** * Grails plugin that provides Server Timing header support for HTTP responses. @@ -15,7 +14,7 @@ import org.springframework.core.Ordered *

Configuration

*

The plugin can be enabled or disabled via the configuration property:

*
- * grails.plugins.serverTiming.enabled = true
+ * grails.plugins.server-timing.enabled = true
  * 
* *

Filter Registration

@@ -26,16 +25,17 @@ import org.springframework.core.Ordered * * * @see ServerTimingFilter - * @see ServerTimingUtils */ @Slf4j class ServerTimingGrailsPlugin extends Plugin { + static final pluginName = 'Server Timing' + /** Minimum Grails version required for this plugin */ def grailsVersion = '7.0.0 > *' /** Plugin title */ - def title = 'Server Timing' + def title = pluginName /** Plugin author */ def author = 'James Daugherty' @@ -51,45 +51,4 @@ class ServerTimingGrailsPlugin extends Plugin { /** Source control management information */ def scm = [url: 'https://github.com/grails-plugins/grails-server-timing'] - - /** - * Registers Spring beans for the Server Timing functionality. - * - *

When the plugin is enabled, this method registers:

- *
    - *
  • serverTimingFilter - The {@link ServerTimingFilter} bean
  • - *
  • serverTimingFilterRegistration - A {@link FilterRegistrationBean} - * that configures the filter to intercept all requests
  • - *
- * - * @return a closure that defines the Spring bean configuration - */ - Closure doWithSpring() { - { -> - if (ServerTimingUtils.instance.enabled) { - serverTimingFilter(ServerTimingFilter) - - serverTimingFilterRegistration(FilterRegistrationBean) { - filter = ref('serverTimingFilter') - urlPatterns = ['/*'] - order = Ordered.HIGHEST_PRECEDENCE + 100 - name = 'serverTimingFilter' - } - } - } - } - - /** - * Performs initialization tasks after the Spring application context is available. - * - *

Logs whether the plugin is enabled or disabled based on the configuration.

- */ - @Override - void doWithApplicationContext() { - if (ServerTimingUtils.instance.enabled) { - log.debug('Applying {} plugin', title) - } else { - log.debug('{} plugin is disabled. Set \'grails.plugins.serverTiming.enabled\' to true to enable it.', title) - } - } } diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy deleted file mode 100644 index 8126af7..0000000 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy +++ /dev/null @@ -1,26 +0,0 @@ -package org.grails.plugins.servertiming - -import grails.util.Environment -import grails.util.Holders -import groovy.transform.CompileStatic - -/** - * Various utilities for configuring the Server Timing plugin - */ -@CompileStatic -@Singleton -class ServerTimingUtils { - - boolean isEnabled() { - Boolean explicitlyEnabled = Holders.config.getProperty('grails.plugins.serverTiming.enabled', Boolean, null) - if (explicitlyEnabled != null) { - return explicitlyEnabled - } - - return Environment.current in [Environment.DEVELOPMENT, Environment.TEST] - } - - String getMetricKey() { - Holders.config.getProperty('grails.plugins.serverTiming.metricKey', String, 'GrailsServerTiming') - } -} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/config/EnabledCondition.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/config/EnabledCondition.groovy new file mode 100644 index 0000000..dd297e0 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/config/EnabledCondition.groovy @@ -0,0 +1,25 @@ +package org.grails.plugins.servertiming.config + +import groovy.transform.CompileStatic + +import org.springframework.context.annotation.Condition +import org.springframework.context.annotation.ConditionContext +import org.springframework.core.env.Environment +import org.springframework.core.type.AnnotatedTypeMetadata + +@CompileStatic +class EnabledCondition implements Condition { + + @Override + boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + matches(context.environment) + } + + static boolean matches(Environment env) { + def explicitConfigValue = env.getProperty('grails.plugins.server-timing.enabled', Boolean, null) + if (explicitConfigValue != null && explicitConfigValue == false) { + return false + } + explicitConfigValue || env.matchesProfiles('development', 'test') + } +} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/config/ServerTimingConfig.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/config/ServerTimingConfig.groovy new file mode 100644 index 0000000..ea2f47f --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/config/ServerTimingConfig.groovy @@ -0,0 +1,14 @@ +package org.grails.plugins.servertiming.config + +import groovy.transform.CompileStatic + +import org.springframework.boot.context.properties.ConfigurationProperties + +@CompileStatic +@ConfigurationProperties('grails.plugins.server-timing') +class ServerTimingConfig { + + Boolean enabled = null + String metricKey = 'GrailsServerTiming' + +} diff --git a/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..8fadc0c --- /dev/null +++ b/plugin/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.grails.plugins.servertiming.ServerTimingAutoConfiguration From 8e0110f9c3cbc31ca1e57ead0b67102a6c3fb4b8 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 23 Feb 2026 16:18:26 +0100 Subject: [PATCH 29/47] docs: use camelCase for config values in docs --- AGENTS.md | 8 ++++---- docs/src/docs/configuration.adoc | 6 +++--- .../app2/ServerTimingDisabledIntegrationSpec.groovy | 2 +- .../plugins/servertiming/ServerTimingInterceptor.groovy | 4 ++-- .../servertiming/ServerTimingAutoConfiguration.groovy | 2 +- .../plugins/servertiming/ServerTimingGrailsPlugin.groovy | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92e3eef..9f8b90b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,10 +130,10 @@ The plugin intercepts HTTP requests via a servlet filter and Grails interceptor: Set in `application.yml`: -| Property | Default | Description | -|-------------------------------------------|--------------------------------------------|-------------------------------------------| -| `grails.plugins.server-timing.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | -| `grails.plugins.server-timing.metric-key` | `GrailsServerTiming` | Request attribute key for storing metrics | +| Property | Default | Description | +|-----------------------------------------|--------------------------------------------|-------------------------------------------| +| `grails.plugins.serverTiming.enabled` | `null` (auto: on in DEV/TEST, off in PROD) | Explicitly enable/disable the plugin | +| `grails.plugins.serverTiming.metricKey` | `GrailsServerTiming` | Request attribute key for storing metrics | **Security note:** The plugin is disabled in production by default because timing data could facilitate timing attacks. diff --git a/docs/src/docs/configuration.adoc b/docs/src/docs/configuration.adoc index 06a2342..ab035eb 100644 --- a/docs/src/docs/configuration.adoc +++ b/docs/src/docs/configuration.adoc @@ -9,7 +9,7 @@ This ensures that performance timing information is available during development === Configuration Options -All configuration options are specified in your `application.yml` or `application.groovy` file under the `grails.plugins.server-timing` namespace. +All configuration options are specified in your `application.yml` or `application.groovy` file under the `grails.plugins.serverTiming` namespace. ==== Enabling/Disabling the Plugin @@ -24,7 +24,7 @@ grails: |=== | Property | Type | Default | Description -| `grails.plugins.server-timing.enabled` +| `grails.plugins.serverTiming.enabled` | `Boolean` | `null` (auto-detect) | When `null`, the plugin is enabled in `development` and `test` environments only. Set to `true` to explicitly enable or `false` to explicitly disable regardless of environment. @@ -46,7 +46,7 @@ grails: |=== | Property | Type | Default | Description -| `grails.plugins.server-timing.metric-key` +| `grails.plugins.serverTiming.metricKey` | `String` | `GrailsServerTiming` | The request attribute key used to store the `TimingMetric` object. Only change this if you have a naming conflict. diff --git a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy index b5c62de..0035ef6 100644 --- a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy +++ b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy @@ -10,7 +10,7 @@ import spock.lang.Specification /** * Integration tests verifying that the Server Timing HTTP header is NOT present * when the plugin is explicitly disabled via configuration - * (grails.plugins.server-timing.enabled: false). + * (grails.plugins.serverTiming.enabled: false). */ @Integration class ServerTimingDisabledIntegrationSpec extends Specification { diff --git a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy index 2f2c56b..e79e7dc 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -24,10 +24,10 @@ class ServerTimingInterceptor implements Interceptor { @Autowired ServerTimingInterceptor(Environment env, ServerTimingConfig config) { if (EnabledCondition.matches(env)) { - log.debug("Server Timing metrics are enabled. Set 'grails.plugins.server-timing.enabled' to false to disable them.") + log.debug("Server Timing metrics are enabled. Set 'grails.plugins.serverTiming.enabled' to false to disable them.") matchAll() } else { - log.debug("Server Timing metrics are disabled. Set 'grails.plugins.server-timing.enabled' to true to enable them.") + log.debug("Server Timing metrics are disabled. Set 'grails.plugins.serverTiming.enabled' to true to enable them.") } metricKey = config.metricKey diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy index f85b663..8739a32 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy @@ -47,7 +47,7 @@ class ServerTimingAutoConfiguration { def applied = !context.getBeanProvider(ServerTimingFilter).stream().findAny().empty def message = applied ? 'Applying {} plugin' : - '{} plugin is disabled. Set \'grails.plugins.server-timing.enabled\' to true to enable it.' + '{} plugin is disabled. Set \'grails.plugins.serverTiming.enabled\' to true to enable it.' log.debug(message, ServerTimingGrailsPlugin.pluginName) } as ApplicationListener } diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy index e5d2967..fc48547 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -14,7 +14,7 @@ import grails.plugins.Plugin *

Configuration

*

The plugin can be enabled or disabled via the configuration property:

*
- * grails.plugins.server-timing.enabled = true
+ * grails.plugins.serverTiming.enabled = true
  * 
* *

Filter Registration

From cb13c15c536750cb2534caf2d06dd330eee8e704 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 23 Feb 2026 16:18:42 +0100 Subject: [PATCH 30/47] chore: add config metadata --- .../resources/spring-configuration-metadata.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 plugin/src/main/resources/spring-configuration-metadata.json diff --git a/plugin/src/main/resources/spring-configuration-metadata.json b/plugin/src/main/resources/spring-configuration-metadata.json new file mode 100644 index 0000000..a7dc431 --- /dev/null +++ b/plugin/src/main/resources/spring-configuration-metadata.json @@ -0,0 +1,15 @@ +{ + "properties": [ + { + "name": "grails.plugins.server-timing.enabled", + "type": "java.lang.Boolean", + "description": "Whether Server Timing is enabled. If not set, resolves to true in development and test, false in production" + }, + { + "name": "grails.plugins.server-timing.metric-key", + "type": "java.lang.String", + "description": "Request attribute key for storing metrics. Change only if you have a naming conflict.", + "defaultValue": "GrailsServerTiming" + } + ] +} From 2d05edf6eafab1b947581254e65608c9ba846684 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 23 Feb 2026 18:17:25 +0100 Subject: [PATCH 31/47] build: refactoring and renaming --- build-logic/build.gradle | 25 ++++--- build-logic/settings.gradle | 4 +- .../src/main/groovy/config.app-debug.gradle | 12 +++ ....style.gradle => config.code-style.gradle} | 15 ++-- .../src/main/groovy/config.compile.gradle | 65 ++++++++++++++++ ...dle => config.coverage-aggregation.gradle} | 59 ++++++++------- .../src/main/groovy/config.docs.gradle | 74 +++++++++++++++++++ ...ample.gradle => config.example-app.gradle} | 6 +- ...ets.gradle => config.grails-assets.gradle} | 14 ++-- ...gin.gradle => config.grails-plugin.gradle} | 8 +- ...lish.gradle => config.publish-root.gradle} | 0 .../src/main/groovy/config.publish.gradle | 13 ++++ ...g.testing.gradle => config.testing.gradle} | 42 ++++++----- ...grails.plugins.servertiming.compile.gradle | 49 ------------ ...rg.grails.plugins.servertiming.docs.gradle | 52 ------------- ...lugins.servertiming.project-publish.gradle | 28 ------- ...org.grails.plugins.servertiming.run.gradle | 10 --- build.gradle | 6 +- coverage/build.gradle | 2 +- docs/build.gradle | 41 +++++----- examples/app1/build.gradle | 52 +++++++------ examples/app2/build.gradle | 51 +++++++------ plugin/build.gradle | 38 +++++++--- 23 files changed, 368 insertions(+), 298 deletions(-) create mode 100644 build-logic/src/main/groovy/config.app-debug.gradle rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.style.gradle => config.code-style.gradle} (66%) create mode 100644 build-logic/src/main/groovy/config.compile.gradle rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.coverage-aggregation.gradle => config.coverage-aggregation.gradle} (52%) create mode 100644 build-logic/src/main/groovy/config.docs.gradle rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.example.gradle => config.example-app.gradle} (51%) rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.assets.gradle => config.grails-assets.gradle} (58%) rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.plugin.gradle => config.grails-plugin.gradle} (55%) rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.root-publish.gradle => config.publish-root.gradle} (100%) create mode 100644 build-logic/src/main/groovy/config.publish.gradle rename build-logic/src/main/groovy/{org.grails.plugins.servertiming.testing.gradle => config.testing.gradle} (75%) delete mode 100644 build-logic/src/main/groovy/org.grails.plugins.servertiming.compile.gradle delete mode 100644 build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle delete mode 100644 build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle delete mode 100644 build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle diff --git a/build-logic/build.gradle b/build-logic/build.gradle index b227d94..ee4464f 100644 --- a/build-logic/build.gradle +++ b/build-logic/build.gradle @@ -2,23 +2,28 @@ plugins { id 'groovy-gradle-plugin' } -file('../gradle.properties').withInputStream { - Properties props = new Properties() - props.load(it) - project.ext.gradleProperties = props +file('../gradle.properties').withInputStream { is -> + extensions.extraProperties.set( + 'gradleProperties', + new Properties().tap { load(is) } + ) } -allprojects { - for (String key : gradleProperties.stringPropertyNames()) { - ext.set(key, gradleProperties.getProperty(key)) +allprojects {project -> + gradleProperties.stringPropertyNames().each { key -> + project.extensions.extraProperties.set( + key, + gradleProperties.getProperty(key) + ) } } dependencies { implementation platform("org.apache.grails:grails-bom:${gradleProperties.grailsVersion}") - implementation 'org.apache.grails:grails-gradle-plugins' - implementation "com.adarshr:gradle-test-logger-plugin:${gradleProperties.testLoggerVersion}" - implementation "org.asciidoctor.jvm.convert:org.asciidoctor.jvm.convert.gradle.plugin:${gradleProperties.asciidoctorVersion}" implementation 'cloud.wondrify:asset-pipeline-gradle' + implementation "com.adarshr:gradle-test-logger-plugin:${gradleProperties.testLoggerVersion}" + implementation 'org.apache.grails:grails-gradle-plugins' implementation 'org.apache.grails.gradle:grails-publish' + implementation "org.asciidoctor.jvm.convert:org.asciidoctor.jvm.convert.gradle.plugin:${gradleProperties.asciidoctorVersion}" } + diff --git a/build-logic/settings.gradle b/build-logic/settings.gradle index 9b949e0..d712464 100644 --- a/build-logic/settings.gradle +++ b/build-logic/settings.gradle @@ -1,6 +1,6 @@ import org.gradle.api.initialization.resolve.RepositoriesMode -rootProject.name = "build-logic" +rootProject.name = 'build-logic' dependencyResolutionManagement { repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS @@ -10,4 +10,4 @@ dependencyResolutionManagement { } maven { url = 'https://repo.grails.org/grails/restricted' } } -} \ No newline at end of file +} diff --git a/build-logic/src/main/groovy/config.app-debug.gradle b/build-logic/src/main/groovy/config.app-debug.gradle new file mode 100644 index 0000000..8363425 --- /dev/null +++ b/build-logic/src/main/groovy/config.app-debug.gradle @@ -0,0 +1,12 @@ +pluginManager.withPlugin('org.springframework.boot') { + tasks.named('bootRun', JavaExec) { + doFirst { + if (project.hasProperty('debugWait')) { + jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005') + } + if (project.hasProperty('debug')) { + jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005') + } + } + } +} diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle b/build-logic/src/main/groovy/config.code-style.gradle similarity index 66% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle rename to build-logic/src/main/groovy/config.code-style.gradle index 1e1359c..91179da 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle +++ b/build-logic/src/main/groovy/config.code-style.gradle @@ -1,15 +1,12 @@ -import org.gradle.api.plugins.quality.CheckstyleExtension -import org.gradle.api.plugins.quality.CodeNarcExtension - plugins { id 'checkstyle' id 'codenarc' } // Resolved relative to the root project directory, which is the parent of build-logic/. -def codeStyleConfigDir = rootProject.file('build-logic/config') -def checkstyleConfigDir = new File(codeStyleConfigDir, 'checkstyle') -def codenarcConfigDir = new File(codeStyleConfigDir, 'codenarc') +def codeStyleConfigDir = rootProject.layout.settingsDirectory.dir('build-logic/config') +def checkstyleConfigDir = codeStyleConfigDir.dir('checkstyle') +def codenarcConfigDir = codeStyleConfigDir.dir('codenarc') extensions.configure(CheckstyleExtension) { it.toolVersion = checkstyleVersion @@ -26,7 +23,7 @@ tasks.withType(Checkstyle).configureEach { extensions.configure(CodeNarcExtension) { it.toolVersion = codenarcVersion - it.configFile = new File(codenarcConfigDir, 'codenarc.groovy') + it.configFile = codenarcConfigDir.file('codenarc.groovy').getAsFile() it.maxPriority1Violations = 0 it.maxPriority2Violations = 0 it.maxPriority3Violations = 0 @@ -40,6 +37,6 @@ tasks.withType(CodeNarc).configureEach { tasks.register('codeStyle') { group = 'verification' description = 'Runs all code style checks (Checkstyle + CodeNarc).' - dependsOn tasks.withType(Checkstyle) - dependsOn tasks.withType(CodeNarc) + dependsOn(tasks.withType(Checkstyle)) + dependsOn(tasks.withType(CodeNarc)) } diff --git a/build-logic/src/main/groovy/config.compile.gradle b/build-logic/src/main/groovy/config.compile.gradle new file mode 100644 index 0000000..457667b --- /dev/null +++ b/build-logic/src/main/groovy/config.compile.gradle @@ -0,0 +1,65 @@ +import java.nio.charset.StandardCharsets + +plugins { + id 'groovy' +} + +tasks.withType(JavaCompile).configureEach { + options.with { + compilerArgs.add('-parameters') + encoding = StandardCharsets.UTF_8.name() + fork = true + incremental = true + release.set(resolveSdkmanJavaMajor(project)) + } + options.forkOptions.with { + jvmArgs.add('-Xmx1g') + memoryMaximumSize = '1g' + } +} + +tasks.withType(GroovyCompile).configureEach { + options.with { + compilerArgs.add('-parameters') + encoding = StandardCharsets.UTF_8.name() + fork = true + incremental = true + } + groovyOptions.with { + encoding = StandardCharsets.UTF_8.name() + optimizationOptions.indy = false + parameters = true + } + groovyOptions.forkOptions.with { + memoryMaximumSize = '1g' + jvmArgs.add('-Xmx1g') + } +} + +private static Provider resolveSdkmanJavaMajor(Project project) { + project.providers.provider { + def sdkmanrc = project.rootProject.file('.sdkmanrc') + if (!sdkmanrc.exists()) { + throw new GradleException('Missing .sdkmanrc in root project') + } + + def props = new Properties() + sdkmanrc.withInputStream { props.load(it) } + + def raw = props.getProperty('java')?.trim() + if (!raw) { + throw new GradleException('Missing java version in root project .sdkmanrc') + } + + def major = raw.tokenize('.').first() + if (!(major ==~ /\d+/)) { + throw new GradleException( + "Invalid java version '$raw' in root project .sdkmanrc (major '$major' is not an integer)" + ) + } + + return major.toInteger() + + } as Provider +} + diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle b/build-logic/src/main/groovy/config.coverage-aggregation.gradle similarity index 52% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle rename to build-logic/src/main/groovy/config.coverage-aggregation.gradle index 277a4f7..b0165a2 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle +++ b/build-logic/src/main/groovy/config.coverage-aggregation.gradle @@ -1,5 +1,3 @@ -import org.gradle.testing.jacoco.plugins.JacocoPluginExtension - plugins { id 'base' id 'jacoco' @@ -10,32 +8,39 @@ extensions.configure(JacocoPluginExtension) { } // Configuration for declaring which projects contribute coverage data. -configurations { - coverageDataProjects { - canBeConsumed = false - canBeResolved = true - } +def coverageDataProjects = configurations.register('coverageDataProjects') { + canBeConsumed = false + canBeResolved = true } // Lazily collect source directories and class files from all coverageDataProjects dependencies. -def covProjectList = configurations.named('coverageDataProjects').map { config -> - config.dependencies.withType(ProjectDependency).collect { project.project(it.path) } +def covProjectList = coverageDataProjects.map { + it.dependencies.withType(ProjectDependency).collect { + project.project(it.path) + } } -def allSourceDirs = covProjectList.map { projects -> - projects.findAll { it.plugins.hasPlugin('java') } - .collectMany { it.sourceSets.main.allSource.sourceDirectories.files } +def allSourceDirs = covProjectList.map { + it.findAll { it.plugins.hasPlugin('java') } + .collectMany { + it.extensions.getByType(SourceSetContainer).named('main').get() + .allSource.sourceDirectories.files + } } -def allClassDirs = covProjectList.map { projects -> - projects.findAll { it.plugins.hasPlugin('java') } - .collectMany { it.sourceSets.main.output.files } +def allClassDirs = covProjectList.map { + it.findAll { it.plugins.hasPlugin('java') } + .collectMany { + it.extensions.getByType(SourceSetContainer).named('main').get() + .output.files + } } -def allExecFiles = covProjectList.map { projects -> - projects.collectMany { prj -> - prj.layout.buildDirectory.dir('jacoco').get().asFile - .listFiles({ File f -> f.name.endsWith('.exec') } as FileFilter)?.toList() ?: [] +def allExecFiles = covProjectList.map { + it.collectMany { + it.fileTree(it.layout.buildDirectory.dir('jacoco')) { + include('**/*.exec') + }.files } } @@ -44,12 +49,12 @@ def allExecFiles = covProjectList.map { projects -> // Task dependencies on all Test tasks (test, integrationTest, etc.) in the declared // projects are derived automatically — no hard-coded project paths needed. tasks.register('jacocoAggregatedReport', JacocoReport) { - group = 'verification' description = 'Generates aggregated JaCoCo coverage report across all subprojects.' + group = 'verification' + classDirectories.from(allClassDirs) executionData.from(allExecFiles) sourceDirectories.from(allSourceDirs) - classDirectories.from(allClassDirs) reports { xml.required = true @@ -61,19 +66,19 @@ tasks.register('jacocoAggregatedReport', JacocoReport) { // After evaluation, wire dependsOn for every Test task in every coverage project. // This ensures all .exec files exist before the aggregated report collects them. afterEvaluate { - def projects = configurations.coverageDataProjects.dependencies + def projects = coverageDataProjects.get().dependencies .withType(ProjectDependency) .collect { project.project(it.path) } - tasks.named('jacocoAggregatedReport') { - projects.each { prj -> - prj.tasks.withType(Test).each { testTask -> - dependsOn testTask + tasks.named('jacocoAggregatedReport') {reportTask -> + projects.each { + it.tasks.withType(Test).configureEach { testTask -> + reportTask.dependsOn(testTask) } } } } tasks.named('check') { - dependsOn tasks.named('jacocoAggregatedReport') + dependsOn('jacocoAggregatedReport') } diff --git a/build-logic/src/main/groovy/config.docs.gradle b/build-logic/src/main/groovy/config.docs.gradle new file mode 100644 index 0000000..6a47c8f --- /dev/null +++ b/build-logic/src/main/groovy/config.docs.gradle @@ -0,0 +1,74 @@ +def docProject = provider { + project(":${project.name - 'root'}docs") +} +def pluginProject = provider { + project(":${project.name - '-root'}") +} + +tasks.register('cleanDocs', Delete) { + description = 'Deletes the documentation output' + group = 'documentation' + + delete(rootProject.layout.projectDirectory.dir('build/docs')) +} + +tasks.register('aggregateGroovyApiDoc', Groovydoc) { + description = 'Generates Groovy API Documentation for the plugin project under build/docs/gapi' + group = 'documentation' + + def upstream = pluginProject.flatMap { + it.tasks.named('groovydoc', Groovydoc) + } as Provider + + dependsOn(tasks.named('cleanDocs')) + dependsOn(upstream) + + access = GroovydocAccess.PROTECTED + includeAuthor = false + includeMainForScripts = true + processScripts = true + exclude('**/Application.groovy') + + + source = { upstream.get().source } + destinationDir = rootProject.layout.buildDirectory.dir('docs/gapi').get().asFile + classpath = files({ upstream.get().classpath }) + groovyClasspath = files({ upstream.get().groovyClasspath }) +} + +tasks.register('docs') { + description = 'Generates the documentation' + group = 'documentation' + + dependsOn( + 'aggregateGroovyApiDoc', + docProject.get().tasks.named('asciidoctor') + ) + finalizedBy( + 'copyAsciiDoctorDocs', + 'ghPagesRootIndexPage' + ) +} + +tasks.register('copyAsciiDoctorDocs', Copy) { + group = 'documentation' + + from(docProject.flatMap { it.layout.buildDirectory }) + into(rootProject.layout.buildDirectory) + include('docs/**') + includeEmptyDirs = false + + dependsOn('docs') +} + +tasks.register('ghPagesRootIndexPage', Copy) { + description = 'Provides a root index page for historical versions that are currently managed manually' + group = 'documentation' + + from(docProject.map { it.layout.projectDirectory.file('src/docs/index.tmpl') }) + into(rootProject.layout.buildDirectory.dir('docs')) + rename('index.tmpl', 'ghpages.html') + + dependsOn('docs') +} + diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.example.gradle b/build-logic/src/main/groovy/config.example-app.gradle similarity index 51% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.example.gradle rename to build-logic/src/main/groovy/config.example-app.gradle index 97ceefb..7d38a79 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.example.gradle +++ b/build-logic/src/main/groovy/config.example-app.gradle @@ -1,6 +1,6 @@ plugins { + id 'config.grails-assets' + id 'config.app-debug' id 'org.apache.grails.gradle.grails-web' id 'org.apache.grails.gradle.grails-gsp' - id 'org.grails.plugins.servertiming.assets' - id 'org.grails.plugins.servertiming.run' -} \ No newline at end of file +} diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.assets.gradle b/build-logic/src/main/groovy/config.grails-assets.gradle similarity index 58% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.assets.gradle rename to build-logic/src/main/groovy/config.grails-assets.gradle index e447d30..57b9381 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.assets.gradle +++ b/build-logic/src/main/groovy/config.grails-assets.gradle @@ -1,20 +1,22 @@ +import asset.pipeline.gradle.AssetPipelineExtension + plugins { id 'cloud.wondrify.asset-pipeline' } dependencies { - assetDevelopmentRuntime 'org.webjars.npm:bootstrap' - assetDevelopmentRuntime 'org.webjars.npm:bootstrap-icons' - assetDevelopmentRuntime 'org.webjars.npm:jquery' + add('assetDevelopmentRuntime', 'org.webjars.npm:bootstrap') + add('assetDevelopmentRuntime', 'org.webjars.npm:bootstrap-icons') + add('assetDevelopmentRuntime', 'org.webjars.npm:jquery') } -assets { - excludes = [ +extensions.configure(AssetPipelineExtension) { + it.excludes = [ 'webjars/jquery/**', 'webjars/bootstrap/**', 'webjars/bootstrap-icons/**' ] - includes = [ + it.includes = [ 'webjars/jquery/*/dist/jquery.js', 'webjars/bootstrap/*/dist/js/bootstrap.bundle.js', 'webjars/bootstrap/*/dist/css/bootstrap.css', diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.plugin.gradle b/build-logic/src/main/groovy/config.grails-plugin.gradle similarity index 55% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.plugin.gradle rename to build-logic/src/main/groovy/config.grails-plugin.gradle index 08f7a9f..dee24f9 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.plugin.gradle +++ b/build-logic/src/main/groovy/config.grails-plugin.gradle @@ -1,8 +1,10 @@ +import org.grails.gradle.plugin.core.GrailsExtension + plugins { id 'org.apache.grails.gradle.grails-plugin' } -grails { +extensions.configure(GrailsExtension) { // Plugins should avoid the spring dependency management plugin due to how it prefers certain libraries - springDependencyManagement = false -} \ No newline at end of file + it.springDependencyManagement = false +} diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.root-publish.gradle b/build-logic/src/main/groovy/config.publish-root.gradle similarity index 100% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.root-publish.gradle rename to build-logic/src/main/groovy/config.publish-root.gradle diff --git a/build-logic/src/main/groovy/config.publish.gradle b/build-logic/src/main/groovy/config.publish.gradle new file mode 100644 index 0000000..5ccbf1a --- /dev/null +++ b/build-logic/src/main/groovy/config.publish.gradle @@ -0,0 +1,13 @@ +plugins { + id 'org.apache.grails.gradle.grails-publish' +} + +// Useful when testing a release version locally and not wanting to setup signing +pluginManager.withPlugin('signing') { + if (System.getenv('DISABLE_BUILD_SIGNING')) { + logger.lifecycle('Signing is disabled for this build per configuration.') + tasks.withType(Sign).configureEach { + enabled = false + } + } +} diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle b/build-logic/src/main/groovy/config.testing.gradle similarity index 75% rename from build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle rename to build-logic/src/main/groovy/config.testing.gradle index 47304a3..3c4b648 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle +++ b/build-logic/src/main/groovy/config.testing.gradle @@ -1,4 +1,4 @@ -import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import com.adarshr.gradle.testlogger.TestLoggerExtension plugins { id 'com.adarshr.test-logger' @@ -11,14 +11,14 @@ def isWindows = System.getProperty('os.name')?.toLowerCase()?.contains('windows' // This configures the 'pretty' test logging // mocha-parallel uses Unicode symbols that require special config on Windows; // standard-parallel is a safe fallback there. -testlogger { - theme isCi ? 'plain-parallel' : (isWindows ? 'standard-parallel' : 'mocha-parallel') - showExceptions true - showStandardStreams false - showSummary true - showPassed true - showSkipped true - showFailed true +extensions.configure(TestLoggerExtension) { + it.theme = isCi ? 'plain-parallel' : (isWindows ? 'standard-parallel' : 'mocha-parallel') + it.showExceptions = true + it.showStandardStreams = false + it.showSummary = true + it.showPassed = true + it.showSkipped = true + it.showFailed = true } extensions.configure(JacocoPluginExtension) { @@ -32,7 +32,7 @@ tasks.withType(Test).configureEach { useJUnitPlatform() - maxHeapSize = "1g" // set to match the groovy compile task to ensure the worker daemons are reused + maxHeapSize = '1g' // set to match the groovy compile task to ensure the worker daemons are reused reports { junitXml.required = false @@ -52,41 +52,47 @@ tasks.withType(Test).configureEach { // The jacoco plugin automatically creates 'jacocoTestReport' for the 'test' task. // Configure it to produce XML (for CI tools) and HTML reports. tasks.named('jacocoTestReport', JacocoReport) { - dependsOn tasks.named('test') - reports { xml.required = true html.required = true csv.required = false } + + dependsOn(tasks.named('test')) } // Ensure coverage report runs after tests tasks.named('test') { - finalizedBy tasks.named('jacocoTestReport') + finalizedBy(tasks.named('jacocoTestReport')) } // When an integrationTest task is registered (e.g., via the Grails web plugin in example apps), // register a JaCoCo report task for it. Unlike the 'test' task, the JaCoCo plugin does not // auto-create report tasks for custom Test tasks. -afterEvaluate { +afterEvaluate { proj -> + def integrationTestTasks = tasks.withType(Test).matching { it.name == 'integrationTest' } if (!integrationTestTasks.isEmpty()) { + def integrationTest = integrationTestTasks.first() def execFile = integrationTest.extensions.getByType(JacocoTaskExtension).destinationFile + def reportTask = tasks.register('jacocoIntegrationTestReport', JacocoReport) { - group = 'verification' description = 'Generates code coverage report for the integrationTest task.' - dependsOn integrationTest + group = 'verification' + executionData.from(execFile) - sourceSets(project.sourceSets.main) + sourceSets(proj.extensions.getByType(SourceSetContainer).named('main').get()) reports { xml.required = true html.required = true csv.required = false } + + dependsOn(integrationTest) } - integrationTest.finalizedBy reportTask + + integrationTest.finalizedBy(reportTask) } } diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.compile.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.compile.gradle deleted file mode 100644 index 9e3c725..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.compile.gradle +++ /dev/null @@ -1,49 +0,0 @@ -import java.nio.charset.StandardCharsets - -plugins { - id 'groovy' -} -tasks.withType(JavaCompile).configureEach { - options.with { - encoding = StandardCharsets.UTF_8.name() - incremental = true - fork = true - compilerArgs += ['-parameters'] - release = project.provider { - def sdkmanrc = project.rootProject.file(".sdkmanrc") - if (!sdkmanrc.exists()) { - throw new GradleException("Missing .sdkmanrc in root project") - } - - Properties props = new Properties() - sdkmanrc.withInputStream { - props.load(it) - } - - props.getProperty('java').split('[.]')[0].toInteger() - } - } - options.forkOptions.with { - memoryMaximumSize = "1g" - jvmArgs = ['-Xmx1g'] - } -} - -tasks.withType(GroovyCompile).configureEach { - options.with { - encoding = StandardCharsets.UTF_8.name() - incremental = true - fork = true - compilerArgs += ['-parameters'] - } - groovyOptions.with { - encoding = StandardCharsets.UTF_8.name() - fork = true - optimizationOptions.indy = false - parameters = true - } - groovyOptions.forkOptions.with { - memoryMaximumSize = "1g" - jvmArgs = ['-Xmx1g'] - } -} diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle deleted file mode 100644 index 76eaf52..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.docs.gradle +++ /dev/null @@ -1,52 +0,0 @@ -Provider docProject = project.provider { - project(":${project.name - 'root' + 'docs'}" as String) -} -Provider pluginProject = project.provider { - project(":${project.name - '-root'}" as String) -} - -tasks.register('cleanDocs', Delete) { - delete rootProject.layout.projectDirectory.dir('build/docs') -} - -tasks.register('aggregateGroovyApiDoc', Groovydoc) { - def groovyDocProjects = [pluginProject.get()] - dependsOn(tasks.named('cleanDocs'), pluginProject.get().tasks.named('groovydoc')) - - description = 'Generates Groovy API Documentation for all plugin projects under rootDir/gapi' - - group = JavaBasePlugin.DOCUMENTATION_GROUP - access = GroovydocAccess.PROTECTED - includeAuthor = false - includeMainForScripts = true - processScripts = true - source = groovyDocProjects.groovydoc.source - destinationDir = rootProject.layout.buildDirectory.dir('docs/gapi').get().asFile - classpath = files(groovyDocProjects.groovydoc.classpath) - groovyClasspath = files(groovyDocProjects.groovydoc.groovyClasspath) - exclude '**/Application.groovy' -} - -tasks.register('docs') { - group = JavaBasePlugin.DOCUMENTATION_GROUP - dependsOn('aggregateGroovyApiDoc', docProject.get().tasks.named('asciidoctor')) - finalizedBy 'copyAsciiDoctorDocs', 'ghPagesRootIndexPage' -} - -tasks.register('copyAsciiDoctorDocs', Copy) { - group = JavaBasePlugin.DOCUMENTATION_GROUP - dependsOn('docs') - from docProject.get().layout.buildDirectory - includes = ['docs/**'] - into rootProject.layout.buildDirectory - includeEmptyDirs = false -} - -// provides a root index page for historical versions that are currently managed manually -tasks.register('ghPagesRootIndexPage', Copy) { - group = 'documentation' - dependsOn('docs') - from docProject.get().layout.projectDirectory.file('src/docs/index.tmpl') - into rootProject.layout.buildDirectory.dir('docs') - rename 'index.tmpl', 'ghpages.html' -} \ No newline at end of file diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle deleted file mode 100644 index 5e4a7ef..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.project-publish.gradle +++ /dev/null @@ -1,28 +0,0 @@ -import org.apache.grails.gradle.publish.GrailsPublishExtension - -plugins { - id 'org.apache.grails.gradle.grails-publish' -} - -extensions.configure(GrailsPublishExtension) { - it.artifactId = project.name - it.githubSlug = 'grails-plugins/grails-server-timing' - it.license.name = 'Apache-2.0' - it.title = 'Grails Server Timing Plugin' - it.desc = 'A Grails Plugin that populates the ServerTiming header for monitoring performance metrics' - it.organization { - it.name = 'Grails Plugins' - it.url = 'https://github.com/grails-plugins' - } - it.developers = [jdaugherty: 'James Daugherty'] -} - -// Useful when testing a release version locally and not wanting to setup signing -project.pluginManager.withPlugin('signing') { - if (System.getenv('DISABLE_BUILD_SIGNING')) { - project.logger.lifecycle('Signing is disabled for this build per configuration.') - project.tasks.withType(Sign).configureEach { - enabled = false - } - } -} \ No newline at end of file diff --git a/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle deleted file mode 100644 index 66719b6..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle +++ /dev/null @@ -1,10 +0,0 @@ -tasks.named('bootRun', JavaExec) { - doFirst { - if (project.hasProperty("debugWait")) { - jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005") - } - if (project.hasProperty("debug")) { - jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005") - } - } -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 2595572..f4e064b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { - id "idea" - id 'org.grails.plugins.servertiming.docs' - id 'org.grails.plugins.servertiming.root-publish' + id 'idea' + id 'config.docs' + id 'config.publish-root' } // Intentionally left blank - use composition instead diff --git a/coverage/build.gradle b/coverage/build.gradle index 8c75d83..0425bdc 100644 --- a/coverage/build.gradle +++ b/coverage/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.grails.plugins.servertiming.coverage-aggregation' + id 'config.coverage-aggregation' } dependencies { diff --git a/docs/build.gradle b/docs/build.gradle index e14902f..6027ac9 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -7,38 +7,39 @@ plugins { version = projectVersion group = 'org.grails.plugins' -String getGrailsDocumentationVersion(String version) { - if (version.endsWith('-SNAPSHOT')) { - return 'snapshot' - } - - return version -} - def asciidoctorAttributes = [ 'source-highlighter': 'coderay', toc : 'left', toclevels : '2', 'toc-title' : 'Table of Contents', icons : 'font', - id : (project.name - '-docs') + ':' + project.version, + id : "${project.name - '-docs'}:${project.version}", idprefix : '', idseparator : '-', version : project.version, - projectUrl : "https://github.com/grails-plugins/grails-server-timing", + projectUrl : 'https://github.com/grails-plugins/grails-server-timing', sourcedir : "${rootProject.allprojects.find { it.name == 'grails-server-timing' }.projectDir}/src/main/groovy", - grailsDocBase : "https://grails.apache.org/docs/${getGrailsDocumentationVersion(project.grailsVersion)}" + grailsDocBase : "https://grails.apache.org/docs/${resolveGrailsDocsDirName(project.grailsVersion)}" ] -tasks.named('asciidoctor', AsciidoctorTask) { AsciidoctorTask it -> - it.jvm { - jvmArgs("--add-opens", "java.base/sun.nio.ch=ALL-UNNAMED", "--add-opens", "java.base/java.io=ALL-UNNAMED") +tasks.named('asciidoctor', AsciidoctorTask) { + + outputDir = project.layout.buildDirectory.dir('docs') + sourceDir = project.layout.projectDirectory.dir('src/docs') + + attributes(asciidoctorAttributes) + baseDirFollowsSourceDir() + options(doctype: 'book') + sources { include 'index.adoc' } + + jvm { + jvmArgs( + '--add-opens', 'java.base/java.io=ALL-UNNAMED', + '--add-opens', 'java.base/sun.nio.ch=ALL-UNNAMED' + ) } +} - it.baseDirFollowsSourceDir() - it.sourceDir project.file('src/docs') - it.sources { include 'index.adoc' } - it.outputDir = project.layout.buildDirectory.dir('docs') - it.options doctype: 'book' - it.attributes asciidoctorAttributes +static String resolveGrailsDocsDirName(String version) { + version.endsWith('-SNAPSHOT') ? 'snapshot' : version } diff --git a/examples/app1/build.gradle b/examples/app1/build.gradle index efe5e92..7330508 100644 --- a/examples/app1/build.gradle +++ b/examples/app1/build.gradle @@ -1,44 +1,50 @@ plugins { - id 'org.grails.plugins.servertiming.compile' - id 'org.grails.plugins.servertiming.testing' - id 'org.grails.plugins.servertiming.example' - id 'org.grails.plugins.servertiming.style' + id 'config.code-style' + id 'config.compile' + id 'config.example-app' + id 'config.testing' } version = projectVersion group = 'app1' dependencies { + + profile 'org.apache.grails.profiles:web' console 'org.apache.grails:grails-console' + implementation platform("org.apache.grails:grails-bom:$grailsVersion") - implementation 'org.springframework.boot:spring-boot-starter-logging' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-autoconfigure' - implementation 'org.springframework.boot:spring-boot-starter' + + implementation project(':grails-server-timing') + implementation 'org.apache.grails:grails-core' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-tomcat' - implementation 'org.apache.grails:grails-web-boot' + implementation 'org.apache.grails:grails-data-hibernate5' + implementation 'org.apache.grails:grails-databinding' + implementation 'org.apache.grails:grails-gsp' + implementation 'org.apache.grails:grails-interceptors' + implementation 'org.apache.grails:grails-layout' implementation 'org.apache.grails:grails-logging' implementation 'org.apache.grails:grails-rest-transforms' - implementation 'org.apache.grails:grails-databinding' + implementation 'org.apache.grails:grails-scaffolding' implementation 'org.apache.grails:grails-services' implementation 'org.apache.grails:grails-url-mappings' - implementation 'org.apache.grails:grails-layout' - implementation 'org.apache.grails:grails-interceptors' - implementation 'org.apache.grails:grails-scaffolding' - implementation 'org.apache.grails:grails-data-hibernate5' - implementation 'org.apache.grails:grails-gsp' - integrationTestImplementation testFixtures('org.apache.grails:grails-geb') - profile 'org.apache.grails.profiles:web' - runtimeOnly 'org.fusesource.jansi:jansi' + implementation 'org.apache.grails:grails-web-boot' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-logging' + implementation 'org.springframework.boot:spring-boot-starter-tomcat' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + runtimeOnly 'cloud.wondrify:asset-pipeline-grails' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.zaxxer:HikariCP' - runtimeOnly 'cloud.wondrify:asset-pipeline-grails' + runtimeOnly 'org.fusesource.jansi:jansi' + + integrationTestImplementation testFixtures('org.apache.grails:grails-geb') testImplementation 'org.apache.grails:grails-testing-support-datamapping' - testImplementation 'org.spockframework:spock-core' testImplementation 'org.apache.grails:grails-testing-support-web' + testImplementation 'org.spockframework:spock-core' - implementation project(':grails-server-timing') } diff --git a/examples/app2/build.gradle b/examples/app2/build.gradle index 5d5db40..f67e57d 100644 --- a/examples/app2/build.gradle +++ b/examples/app2/build.gradle @@ -1,44 +1,49 @@ plugins { - id 'org.grails.plugins.servertiming.compile' - id 'org.grails.plugins.servertiming.testing' - id 'org.grails.plugins.servertiming.example' - id 'org.grails.plugins.servertiming.style' + id 'config.code-style' + id 'config.compile' + id 'config.example-app' + id 'config.testing' } version = projectVersion group = 'app2' dependencies { + + profile 'org.apache.grails.profiles:web' console 'org.apache.grails:grails-console' + implementation platform("org.apache.grails:grails-bom:$grailsVersion") - implementation 'org.springframework.boot:spring-boot-starter-logging' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-autoconfigure' - implementation 'org.springframework.boot:spring-boot-starter' + + implementation project(':grails-server-timing') + implementation 'org.apache.grails:grails-core' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-tomcat' - implementation 'org.apache.grails:grails-web-boot' + implementation 'org.apache.grails:grails-data-hibernate5' + implementation 'org.apache.grails:grails-databinding' + implementation 'org.apache.grails:grails-gsp' + implementation 'org.apache.grails:grails-interceptors' + implementation 'org.apache.grails:grails-layout' implementation 'org.apache.grails:grails-logging' implementation 'org.apache.grails:grails-rest-transforms' - implementation 'org.apache.grails:grails-databinding' + implementation 'org.apache.grails:grails-scaffolding' implementation 'org.apache.grails:grails-services' implementation 'org.apache.grails:grails-url-mappings' - implementation 'org.apache.grails:grails-layout' - implementation 'org.apache.grails:grails-interceptors' - implementation 'org.apache.grails:grails-scaffolding' - implementation 'org.apache.grails:grails-data-hibernate5' - implementation 'org.apache.grails:grails-gsp' - integrationTestImplementation testFixtures('org.apache.grails:grails-geb') - profile 'org.apache.grails.profiles:web' - runtimeOnly 'org.fusesource.jansi:jansi' + implementation 'org.apache.grails:grails-web-boot' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-logging' + implementation 'org.springframework.boot:spring-boot-starter-tomcat' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + runtimeOnly 'cloud.wondrify:asset-pipeline-grails' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.zaxxer:HikariCP' - runtimeOnly 'cloud.wondrify:asset-pipeline-grails' + runtimeOnly 'org.fusesource.jansi:jansi' testImplementation 'org.apache.grails:grails-testing-support-datamapping' - testImplementation 'org.spockframework:spock-core' testImplementation 'org.apache.grails:grails-testing-support-web' + testImplementation 'org.spockframework:spock-core' - implementation project(':grails-server-timing') + integrationTestImplementation testFixtures('org.apache.grails:grails-geb') } diff --git a/plugin/build.gradle b/plugin/build.gradle index 079310d..4afa31c 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -1,22 +1,38 @@ +import org.apache.grails.gradle.publish.GrailsPublishExtension + plugins { - id 'org.grails.plugins.servertiming.compile' - id 'org.grails.plugins.servertiming.testing' - id 'org.grails.plugins.servertiming.plugin' - id 'org.grails.plugins.servertiming.project-publish' - id 'org.grails.plugins.servertiming.style' + id 'config.code-style' + id 'config.compile' + id 'config.grails-plugin' + id 'config.publish' + id 'config.testing' } version = projectVersion -group = "org.grails.plugins" +group = 'org.grails.plugins' dependencies { + + profile 'org.apache.grails.profiles:web-plugin' + console 'org.apache.grails:grails-console' + compileOnly platform("org.apache.grails:grails-bom:$grailsVersion") compileOnly 'org.apache.grails:grails-dependencies-starter-web' - console "org.apache.grails:grails-console" - profile "org.apache.grails.profiles:web-plugin" - testImplementation platform("org.apache.grails:grails-bom:$grailsVersion") - testImplementation "org.apache.grails:grails-dependencies-starter-web" - testImplementation "org.apache.grails:grails-dependencies-test" + testImplementation 'org.apache.grails:grails-dependencies-starter-web' + testImplementation 'org.apache.grails:grails-dependencies-test' +} + +extensions.configure(GrailsPublishExtension) { + it.artifactId = project.name + it.githubSlug = 'grails-plugins/grails-server-timing' + it.license.name = 'Apache-2.0' + it.title = 'Grails Server Timing Plugin' + it.desc = 'A Grails Plugin that populates the Server-Timing http header for monitoring performance metrics' + it.organization { + it.name = 'Grails Plugins' + it.url = 'https://github.com/grails-plugins' + } + it.developers = [jdaugherty: 'James Daugherty'] } From 42f134cdc0f4c4bde979678acc8be5ecd519fa84 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 23 Feb 2026 18:19:53 +0100 Subject: [PATCH 32/47] chore: whitespace --- build-logic/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/build.gradle b/build-logic/build.gradle index ee4464f..746607d 100644 --- a/build-logic/build.gradle +++ b/build-logic/build.gradle @@ -9,7 +9,7 @@ file('../gradle.properties').withInputStream { is -> ) } -allprojects {project -> +allprojects { project -> gradleProperties.stringPropertyNames().each { key -> project.extensions.extraProperties.set( key, From 00c2812bbee34a87f35464c09e607df15da28928 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 08:12:37 +0100 Subject: [PATCH 33/47] build: rename app-debug to app-run --- .../groovy/{config.app-debug.gradle => config.app-run.gradle} | 0 build-logic/src/main/groovy/config.example-app.gradle | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename build-logic/src/main/groovy/{config.app-debug.gradle => config.app-run.gradle} (100%) diff --git a/build-logic/src/main/groovy/config.app-debug.gradle b/build-logic/src/main/groovy/config.app-run.gradle similarity index 100% rename from build-logic/src/main/groovy/config.app-debug.gradle rename to build-logic/src/main/groovy/config.app-run.gradle diff --git a/build-logic/src/main/groovy/config.example-app.gradle b/build-logic/src/main/groovy/config.example-app.gradle index 7d38a79..da58c2a 100644 --- a/build-logic/src/main/groovy/config.example-app.gradle +++ b/build-logic/src/main/groovy/config.example-app.gradle @@ -1,6 +1,6 @@ plugins { + id 'config.app-run' id 'config.grails-assets' - id 'config.app-debug' id 'org.apache.grails.gradle.grails-web' id 'org.apache.grails.gradle.grails-gsp' } From 843c7499f0f7c7401e5e51d382a381781cf72cdc Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 09:00:20 +0100 Subject: [PATCH 34/47] build: refactor code coverage --- ...-aggregation.gradle => config.code-coverage.gradle} | 10 ++++++---- coverage/build.gradle | 6 +++--- gradle.properties | 5 ++++- 3 files changed, 13 insertions(+), 8 deletions(-) rename build-logic/src/main/groovy/{config.coverage-aggregation.gradle => config.code-coverage.gradle} (94%) diff --git a/build-logic/src/main/groovy/config.coverage-aggregation.gradle b/build-logic/src/main/groovy/config.code-coverage.gradle similarity index 94% rename from build-logic/src/main/groovy/config.coverage-aggregation.gradle rename to build-logic/src/main/groovy/config.code-coverage.gradle index b0165a2..5306ff7 100644 --- a/build-logic/src/main/groovy/config.coverage-aggregation.gradle +++ b/build-logic/src/main/groovy/config.code-coverage.gradle @@ -1,10 +1,9 @@ plugins { - id 'base' id 'jacoco' } extensions.configure(JacocoPluginExtension) { - it.toolVersion = '0.8.12' + it.toolVersion = jacocoVersion } // Configuration for declaring which projects contribute coverage data. @@ -79,6 +78,9 @@ afterEvaluate { } } -tasks.named('check') { - dependsOn('jacocoAggregatedReport') +pluginManager.withPlugin('base') { + tasks.named('check') { + dependsOn('jacocoAggregatedReport') + } } + diff --git a/coverage/build.gradle b/coverage/build.gradle index 0425bdc..41545f6 100644 --- a/coverage/build.gradle +++ b/coverage/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'config.coverage-aggregation' + id 'config.code-coverage' } dependencies { @@ -7,7 +7,7 @@ dependencies { coverageDataProjects project(':grails-server-timing') // Auto-discover all example apps under examples/ - rootDir.toPath().resolve('examples').toFile().list()?.each { example -> - coverageDataProjects project(":$example") + rootDir.toPath().resolve('examples').toFile().list()?.each { exampleApp -> + coverageDataProjects project(":$exampleApp") } } diff --git a/gradle.properties b/gradle.properties index 7681403..b118218 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,12 @@ projectVersion=0.0.1-SNAPSHOT grailsVersion=7.0.7 + +# Build dependencies +asciidoctorVersion=4.0.5 checkstyleVersion=10.21.4 codenarcVersion=3.6.0 +jacocoVersion=0.8.12 testLoggerVersion=4.0.0 -asciidoctorVersion=4.0.5 org.gradle.caching=true org.gradle.daemon=true From eca597f175f377fb0101f0ddfbbff206b6281969 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 10:29:44 +0100 Subject: [PATCH 35/47] build: extract code coverage from testing --- .../config.code-coverage-aggregate.gradle | 86 ++++++++++++++++ .../main/groovy/config.code-coverage.gradle | 97 +++++++------------ .../src/main/groovy/config.example-app.gradle | 4 + .../src/main/groovy/config.testing.gradle | 53 ---------- code-coverage/build.gradle | 14 +++ coverage/build.gradle | 13 --- examples/app1/build.gradle | 3 - examples/app2/build.gradle | 3 - plugin/build.gradle | 1 + settings.gradle | 20 ++-- 10 files changed, 150 insertions(+), 144 deletions(-) create mode 100644 build-logic/src/main/groovy/config.code-coverage-aggregate.gradle create mode 100644 code-coverage/build.gradle delete mode 100644 coverage/build.gradle diff --git a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle new file mode 100644 index 0000000..5306ff7 --- /dev/null +++ b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle @@ -0,0 +1,86 @@ +plugins { + id 'jacoco' +} + +extensions.configure(JacocoPluginExtension) { + it.toolVersion = jacocoVersion +} + +// Configuration for declaring which projects contribute coverage data. +def coverageDataProjects = configurations.register('coverageDataProjects') { + canBeConsumed = false + canBeResolved = true +} + +// Lazily collect source directories and class files from all coverageDataProjects dependencies. +def covProjectList = coverageDataProjects.map { + it.dependencies.withType(ProjectDependency).collect { + project.project(it.path) + } +} + +def allSourceDirs = covProjectList.map { + it.findAll { it.plugins.hasPlugin('java') } + .collectMany { + it.extensions.getByType(SourceSetContainer).named('main').get() + .allSource.sourceDirectories.files + } +} + +def allClassDirs = covProjectList.map { + it.findAll { it.plugins.hasPlugin('java') } + .collectMany { + it.extensions.getByType(SourceSetContainer).named('main').get() + .output.files + } +} + +def allExecFiles = covProjectList.map { + it.collectMany { + it.fileTree(it.layout.buildDirectory.dir('jacoco')) { + include('**/*.exec') + }.files + } +} + +// Register the aggregated coverage report task. +// This merges JaCoCo execution data from all coverageDataProjects into a single report. +// Task dependencies on all Test tasks (test, integrationTest, etc.) in the declared +// projects are derived automatically — no hard-coded project paths needed. +tasks.register('jacocoAggregatedReport', JacocoReport) { + description = 'Generates aggregated JaCoCo coverage report across all subprojects.' + group = 'verification' + + classDirectories.from(allClassDirs) + executionData.from(allExecFiles) + sourceDirectories.from(allSourceDirs) + + reports { + xml.required = true + html.required = true + csv.required = false + } +} + +// After evaluation, wire dependsOn for every Test task in every coverage project. +// This ensures all .exec files exist before the aggregated report collects them. +afterEvaluate { + def projects = coverageDataProjects.get().dependencies + .withType(ProjectDependency) + .collect { project.project(it.path) } + + tasks.named('jacocoAggregatedReport') {reportTask -> + projects.each { + it.tasks.withType(Test).configureEach { testTask -> + reportTask.dependsOn(testTask) + } + } + } +} + +pluginManager.withPlugin('base') { + tasks.named('check') { + dependsOn('jacocoAggregatedReport') + } +} + diff --git a/build-logic/src/main/groovy/config.code-coverage.gradle b/build-logic/src/main/groovy/config.code-coverage.gradle index 5306ff7..e5e0aab 100644 --- a/build-logic/src/main/groovy/config.code-coverage.gradle +++ b/build-logic/src/main/groovy/config.code-coverage.gradle @@ -6,81 +6,52 @@ extensions.configure(JacocoPluginExtension) { it.toolVersion = jacocoVersion } -// Configuration for declaring which projects contribute coverage data. -def coverageDataProjects = configurations.register('coverageDataProjects') { - canBeConsumed = false - canBeResolved = true -} +pluginManager.withPlugin('groovy') { + // The jacoco plugin automatically creates 'jacocoTestReport' for the 'test' task. + // Configure it to produce XML (for CI tools) and HTML reports. + tasks.named('jacocoTestReport', JacocoReport) { + reports { + xml.required = true + html.required = true + csv.required = false + } -// Lazily collect source directories and class files from all coverageDataProjects dependencies. -def covProjectList = coverageDataProjects.map { - it.dependencies.withType(ProjectDependency).collect { - project.project(it.path) + dependsOn(tasks.named('test')) } -} - -def allSourceDirs = covProjectList.map { - it.findAll { it.plugins.hasPlugin('java') } - .collectMany { - it.extensions.getByType(SourceSetContainer).named('main').get() - .allSource.sourceDirectories.files - } -} - -def allClassDirs = covProjectList.map { - it.findAll { it.plugins.hasPlugin('java') } - .collectMany { - it.extensions.getByType(SourceSetContainer).named('main').get() - .output.files - } -} -def allExecFiles = covProjectList.map { - it.collectMany { - it.fileTree(it.layout.buildDirectory.dir('jacoco')) { - include('**/*.exec') - }.files + // Ensure coverage report runs after tests + tasks.named('test') { + finalizedBy(tasks.named('jacocoTestReport')) } } -// Register the aggregated coverage report task. -// This merges JaCoCo execution data from all coverageDataProjects into a single report. -// Task dependencies on all Test tasks (test, integrationTest, etc.) in the declared -// projects are derived automatically — no hard-coded project paths needed. -tasks.register('jacocoAggregatedReport', JacocoReport) { - description = 'Generates aggregated JaCoCo coverage report across all subprojects.' - group = 'verification' +// When an integrationTest task is registered (e.g., via the Grails web plugin in example apps), +// register a JaCoCo report task for it. Unlike the 'test' task, the JaCoCo plugin does not +// auto-create report tasks for custom Test tasks. +afterEvaluate { proj -> - classDirectories.from(allClassDirs) - executionData.from(allExecFiles) - sourceDirectories.from(allSourceDirs) + def integrationTestTasks = tasks.withType(Test).matching { it.name == 'integrationTest' } + if (!integrationTestTasks.isEmpty()) { - reports { - xml.required = true - html.required = true - csv.required = false - } -} + def integrationTest = integrationTestTasks.first() + def execFile = integrationTest.extensions.getByType(JacocoTaskExtension).destinationFile + + def reportTask = tasks.register('jacocoIntegrationTestReport', JacocoReport) { + description = 'Generates code coverage report for the integrationTest task.' + group = 'verification' -// After evaluation, wire dependsOn for every Test task in every coverage project. -// This ensures all .exec files exist before the aggregated report collects them. -afterEvaluate { - def projects = coverageDataProjects.get().dependencies - .withType(ProjectDependency) - .collect { project.project(it.path) } + executionData.from(execFile) + sourceSets(proj.extensions.getByType(SourceSetContainer).named('main').get()) - tasks.named('jacocoAggregatedReport') {reportTask -> - projects.each { - it.tasks.withType(Test).configureEach { testTask -> - reportTask.dependsOn(testTask) + reports { + xml.required = true + html.required = true + csv.required = false } + + dependsOn(integrationTest) } - } -} -pluginManager.withPlugin('base') { - tasks.named('check') { - dependsOn('jacocoAggregatedReport') + integrationTest.finalizedBy(reportTask) } } - diff --git a/build-logic/src/main/groovy/config.example-app.gradle b/build-logic/src/main/groovy/config.example-app.gradle index da58c2a..ab33f6e 100644 --- a/build-logic/src/main/groovy/config.example-app.gradle +++ b/build-logic/src/main/groovy/config.example-app.gradle @@ -1,6 +1,10 @@ plugins { id 'config.app-run' + id 'config.code-coverage' + id 'config.code-style' + id 'config.compile' id 'config.grails-assets' + id 'config.testing' id 'org.apache.grails.gradle.grails-web' id 'org.apache.grails.gradle.grails-gsp' } diff --git a/build-logic/src/main/groovy/config.testing.gradle b/build-logic/src/main/groovy/config.testing.gradle index 3c4b648..667d35b 100644 --- a/build-logic/src/main/groovy/config.testing.gradle +++ b/build-logic/src/main/groovy/config.testing.gradle @@ -2,7 +2,6 @@ import com.adarshr.gradle.testlogger.TestLoggerExtension plugins { id 'com.adarshr.test-logger' - id 'jacoco' } def isCi = System.getenv('CI') != null @@ -21,10 +20,6 @@ extensions.configure(TestLoggerExtension) { it.showFailed = true } -extensions.configure(JacocoPluginExtension) { - it.toolVersion = '0.8.12' -} - tasks.withType(Test).configureEach { onlyIf { !project.hasProperty('skipTests') @@ -48,51 +43,3 @@ tasks.withType(Test).configureEach { showCauses = true } } - -// The jacoco plugin automatically creates 'jacocoTestReport' for the 'test' task. -// Configure it to produce XML (for CI tools) and HTML reports. -tasks.named('jacocoTestReport', JacocoReport) { - reports { - xml.required = true - html.required = true - csv.required = false - } - - dependsOn(tasks.named('test')) -} - -// Ensure coverage report runs after tests -tasks.named('test') { - finalizedBy(tasks.named('jacocoTestReport')) -} - -// When an integrationTest task is registered (e.g., via the Grails web plugin in example apps), -// register a JaCoCo report task for it. Unlike the 'test' task, the JaCoCo plugin does not -// auto-create report tasks for custom Test tasks. -afterEvaluate { proj -> - - def integrationTestTasks = tasks.withType(Test).matching { it.name == 'integrationTest' } - if (!integrationTestTasks.isEmpty()) { - - def integrationTest = integrationTestTasks.first() - def execFile = integrationTest.extensions.getByType(JacocoTaskExtension).destinationFile - - def reportTask = tasks.register('jacocoIntegrationTestReport', JacocoReport) { - description = 'Generates code coverage report for the integrationTest task.' - group = 'verification' - - executionData.from(execFile) - sourceSets(proj.extensions.getByType(SourceSetContainer).named('main').get()) - - reports { - xml.required = true - html.required = true - csv.required = false - } - - dependsOn(integrationTest) - } - - integrationTest.finalizedBy(reportTask) - } -} diff --git a/code-coverage/build.gradle b/code-coverage/build.gradle new file mode 100644 index 0000000..f8adea9 --- /dev/null +++ b/code-coverage/build.gradle @@ -0,0 +1,14 @@ +plugins { + id 'config.code-coverage-aggregate' +} + +dependencies { + // The plugin project (always included) + coverageDataProjects project(':grails-server-timing') + + // Auto-discover all example apps under examples/ + rootDir.toPath().resolve('examples').toFile() + .listFiles({ it.directory } as FileFilter) + .each { coverageDataProjects project(":$it.name") + } +} diff --git a/coverage/build.gradle b/coverage/build.gradle deleted file mode 100644 index 41545f6..0000000 --- a/coverage/build.gradle +++ /dev/null @@ -1,13 +0,0 @@ -plugins { - id 'config.code-coverage' -} - -dependencies { - // The plugin project (always included) - coverageDataProjects project(':grails-server-timing') - - // Auto-discover all example apps under examples/ - rootDir.toPath().resolve('examples').toFile().list()?.each { exampleApp -> - coverageDataProjects project(":$exampleApp") - } -} diff --git a/examples/app1/build.gradle b/examples/app1/build.gradle index 7330508..dc6e54c 100644 --- a/examples/app1/build.gradle +++ b/examples/app1/build.gradle @@ -1,8 +1,5 @@ plugins { - id 'config.code-style' - id 'config.compile' id 'config.example-app' - id 'config.testing' } version = projectVersion diff --git a/examples/app2/build.gradle b/examples/app2/build.gradle index f67e57d..661d543 100644 --- a/examples/app2/build.gradle +++ b/examples/app2/build.gradle @@ -1,8 +1,5 @@ plugins { - id 'config.code-style' - id 'config.compile' id 'config.example-app' - id 'config.testing' } version = projectVersion diff --git a/plugin/build.gradle b/plugin/build.gradle index 4afa31c..6da6ea7 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -1,6 +1,7 @@ import org.apache.grails.gradle.publish.GrailsPublishExtension plugins { + id 'config.code-coverage' id 'config.code-style' id 'config.compile' id 'config.grails-plugin' diff --git a/settings.gradle b/settings.gradle index 87c6a01..aa039a9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,8 +22,11 @@ def isLocal = !isCI def isReproducibleBuild = System.getenv('SOURCE_DATE_EPOCH') != null if (isReproducibleBuild) { gradle.settingsEvaluated { - logger.warn('*************** Remote Build Cache Disabled due to Reproducible Build ********************') - logger.warn("Build date will be set to (SOURCE_DATE_EPOCH=${System.getenv("SOURCE_DATE_EPOCH")})") + logger.warn( + '***** Remote Build Cache Disabled due to Reproducible Build *****\n' + + 'Build date will be set to (SOURCE_DATE_EPOCH={})', + System.getenv('SOURCE_DATE_EPOCH') + ) } } @@ -33,16 +36,15 @@ buildCache { rootProject.name = 'grails-server-timing-root' -include 'plugin' +include('plugin') project(':plugin').name = 'grails-server-timing' -include 'docs' +include('docs') project(':docs').name = 'grails-server-timing-docs' -include 'coverage' +include('code-coverage') -def examples = file('examples').list() -examples.each { example -> - include example - project(":$example").projectDir = file("examples/$example") +file('examples').listFiles({ it.directory } as FileFilter).each { + include(it.name) + project(":$it.name").projectDir = file("examples/$it.name") } dependencyResolutionManagement { From 64a9bf275368e5a439022eda78c349c6508b56cb Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 10:54:49 +0100 Subject: [PATCH 36/47] ci: update workflows --- .github/workflows/ci.yml | 20 ++++++------ .../{coverage.yml => code-coverage.yml} | 14 ++++----- .github/workflows/code-style.yml | 6 ++-- .github/workflows/release.yml | 31 ++++++++++--------- 4 files changed, 35 insertions(+), 36 deletions(-) rename .github/workflows/{coverage.yml => code-coverage.yml} (89%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 957189e..cd6476c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: "Output Agent IP" # in the event your agent has network issues, you can use this to debug run: curl -s https://api.ipify.org - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: "Export .sdkmanrc properties" uses: apache/grails-github-actions/export-gradle-properties@asf with: @@ -28,14 +28,14 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: 'Ensure Common Build Date' # to ensure a reproducible build run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "🔨 Build project without tests" if: ${{ contains(github.event.head_commit.message, '[skip tests]') }} run: > @@ -53,15 +53,16 @@ jobs: --rerun-tasks -PskipCodeStyle publish: - # only run the publish task on this repo instead of forks + # only run the publishing task on this repo (not on forks) if: github.repository_owner == 'grails-plugins' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - needs: [ build ] + needs: build + name: "Publish Snapshot" runs-on: ubuntu-24.04 steps: - name: "Output Agent IP" # in the event your agent has network issues, you can use this to debug run: curl -s https://api.ipify.org - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: "Export .sdkmanrc properties" uses: apache/grails-github-actions/export-gradle-properties@asf with: @@ -70,30 +71,27 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: 'Ensure Common Build Date' # to ensure a reproducible build run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "📤 Publish Gradle Snapshot Artifacts" env: GRAILS_PUBLISH_RELEASE: 'false' MAVEN_PUBLISH_URL: 'https://central.sonatype.com/repository/maven-snapshots/' MAVEN_PUBLISH_USERNAME: ${{ secrets.NEXUS_PUBLISH_USERNAME }} MAVEN_PUBLISH_PASSWORD: ${{ secrets.NEXUS_PUBLISH_PASSWORD }} - working-directory: './plugin' run: > ../gradlew publish --no-build-cache --rerun-tasks - name: "📜 Generate Documentation" - if: success() run: ./gradlew docs - name: "🚀 Publish to Github Pages" - if: success() uses: apache/grails-github-actions/deploy-github-pages@asf env: GRADLE_PUBLISH_RELEASE: 'false' diff --git a/.github/workflows/coverage.yml b/.github/workflows/code-coverage.yml similarity index 89% rename from .github/workflows/coverage.yml rename to .github/workflows/code-coverage.yml index c72adc3..4f459d9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -1,4 +1,4 @@ -name: "Coverage" +name: "Code Coverage" on: push: branches: @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: "Export .sdkmanrc properties" uses: apache/grails-github-actions/export-gradle-properties@asf with: @@ -24,24 +24,24 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: liberica java-version: ${{ env.SDKMANRC_java }} - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "🔨 Build and run tests" run: > ./gradlew build --continue --stacktrace -PskipCodeStyle - - name: "📊 Post coverage summary" + - name: "📊 Post code coverage summary" if: always() run: | - REPORT="coverage/build/reports/jacoco/jacocoAggregatedReport/jacocoAggregatedReport.xml" + REPORT="code-coverage/build/reports/jacoco/jacocoAggregatedReport/jacocoAggregatedReport.xml" if [ ! -f "$REPORT" ]; then - echo "::warning::Coverage report not found at $REPORT" + echo "::warning::Code Coverage report not found at $REPORT" exit 0 fi diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index bc649de..7fdf71f 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: "Export .sdkmanrc properties" uses: apache/grails-github-actions/export-gradle-properties@asf with: @@ -24,12 +24,12 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: liberica java-version: ${{ env.SDKMANRC_java }} - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "🎨 Run code style checks" run: > ./gradlew codeStyle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0274660..47aa465 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,9 @@ on: types: [ published ] permissions: { } env: - # to prevent throttling of the github api, include the github token in an environment variable since the build will check for it + # To prevent throttling of the GitHub api, + # include the GitHub token in an environment variable + # since the build will check for it GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GRAILS_PUBLISH_RELEASE: 'true' JAVA_DISTRIBUTION: 'liberica' @@ -19,7 +21,7 @@ jobs: name: "Stage Jar Files" permissions: packages: read # pre-release workflow - contents: write # to create release + contents: write # to create a release issues: write # to modify milestones runs-on: ubuntu-24.04 steps: @@ -28,7 +30,7 @@ jobs: - name: "Output Agent IP" # in the event RAO blocks this agent, this can be used to debug it run: curl -s https://api.ipify.org - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ env.TAG }} @@ -49,12 +51,12 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "⚙️ Run pre-release" uses: apache/grails-github-actions/pre-release@asf env: @@ -71,7 +73,6 @@ jobs: ./gradlew -Psigning.secretKeyRingFile=${{ github.workspace }}/secring.gpg publishMavenPublicationToSonatypeRepository - publishPluginMavenPublicationToSonatypeRepository closeSonatypeStagingRepository - name: "Generate Build Date file" run: echo "$SOURCE_DATE_EPOCH" >> build/BUILD_DATE.txt @@ -92,7 +93,7 @@ jobs: - name: "📝 Establish release version" run: echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ env.TAG }} @@ -104,12 +105,12 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "📤 Release staging repository" env: NEXUS_PUBLISH_USERNAME: ${{ secrets.MAVEN_USERNAME }} @@ -131,7 +132,7 @@ jobs: - name: "📝 Establish release version" run: echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ env.TAG }} @@ -143,12 +144,12 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "🔨 Build Documentation" run: ./gradlew docs - name: "🚀 Publish to Github Pages" @@ -171,7 +172,7 @@ jobs: - name: "📝 Establish release version" run: echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" - name: "📥 Checkout repository" - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ env.TAG }} @@ -183,11 +184,11 @@ jobs: - name: "Determine Java Version" run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: "⚙️ Run post-release" uses: apache/grails-github-actions/post-release@asf From 358cd0665e25a9bdbfc0853742de23103dd91454 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 11:38:39 +0100 Subject: [PATCH 37/47] build: fix code coverage aggregation --- .../src/main/groovy/config.code-coverage-aggregate.gradle | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle index 5306ff7..595e563 100644 --- a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle +++ b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle @@ -1,4 +1,5 @@ plugins { + id 'base' id 'jacoco' } @@ -78,9 +79,6 @@ afterEvaluate { } } -pluginManager.withPlugin('base') { - tasks.named('check') { - dependsOn('jacocoAggregatedReport') - } +tasks.named('check') { + dependsOn('jacocoAggregatedReport') } - From fd5e95fb7982d9787a86c8ab913ddd9bfbef00be Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 12:45:56 +0100 Subject: [PATCH 38/47] ci: setup conditional build scans --- .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/code-coverage.yml | 6 ++++++ .github/workflows/code-style.yml | 6 ++++++ .github/workflows/release.yml | 18 ++++++++++++++++++ gradle.properties | 5 +++++ 5 files changed, 47 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd6476c..d6d903f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,14 @@ jobs: java-version: ${{ env.SDKMANRC_java }} - name: 'Ensure Common Build Date' # to ensure a reproducible build run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "🔨 Build project without tests" if: ${{ contains(github.event.head_commit.message, '[skip tests]') }} run: > @@ -77,8 +83,14 @@ jobs: java-version: ${{ env.SDKMANRC_java }} - name: 'Ensure Common Build Date' # to ensure a reproducible build run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "📤 Publish Gradle Snapshot Artifacts" env: GRAILS_PUBLISH_RELEASE: 'false' diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 4f459d9..eebb2f1 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -28,8 +28,14 @@ jobs: with: distribution: liberica java-version: ${{ env.SDKMANRC_java }} + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "🔨 Build and run tests" run: > ./gradlew build diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index 7fdf71f..db6872e 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -28,8 +28,14 @@ jobs: with: distribution: liberica java-version: ${{ env.SDKMANRC_java }} + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "🎨 Run code style checks" run: > ./gradlew codeStyle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47aa465..a008a0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,14 @@ jobs: with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "⚙️ Run pre-release" uses: apache/grails-github-actions/pre-release@asf env: @@ -109,8 +115,14 @@ jobs: with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "📤 Release staging repository" env: NEXUS_PUBLISH_USERNAME: ${{ secrets.MAVEN_USERNAME }} @@ -148,8 +160,14 @@ jobs: with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.SDKMANRC_java }} + - name: "Export gradle.properties properties" + uses: apache/grails-github-actions/export-gradle-properties@asf - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v5 + with: + build-scan-publish: ${{ env.ciBuildScanPublish }} + build-scan-terms-of-use-url: ${{ env.ciBuildScanTermsOfUseUrl }} + build-scan-terms-of-use-agree: ${{ env.ciBuildScanTermsOfUseAgree }} - name: "🔨 Build Documentation" run: ./gradlew docs - name: "🚀 Publish to Github Pages" diff --git a/gradle.properties b/gradle.properties index b118218..e62c934 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,6 +8,11 @@ codenarcVersion=3.6.0 jacocoVersion=0.8.12 testLoggerVersion=4.0.0 +# Enable and set agree=yes to publish build scans from GitHub workflows +ciBuildScanPublish=true +ciBuildScanTermsOfUseUrl=https://gradle.com/terms-of-service +ciBuildScanTermsOfUseAgree=yes + org.gradle.caching=true org.gradle.daemon=true org.gradle.parallel=true From 9bfc3f13eb9d5963f967a3b0f3f19a08a2156005 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 13:47:15 +0100 Subject: [PATCH 39/47] ci: clean up post-release --- .github/workflows/release.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a008a0a..d04f043 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,26 +187,5 @@ jobs: issues: write # required for milestone closing pull-requests: write # to create the PR that will increment the version steps: - - name: "📝 Establish release version" - run: echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" - - name: "📥 Checkout repository" - uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ref: ${{ env.TAG }} - - name: "Export .sdkmanrc properties" - uses: apache/grails-github-actions/export-gradle-properties@asf - with: - file: ".sdkmanrc" - prefix: "SDKMANRC_" - - name: "Determine Java Version" - run: echo "SDKMANRC_java=${{ env.SDKMANRC_java }}" | sed 's/-.*//' >> $GITHUB_ENV - - name: "☕️ Setup JDK" - uses: actions/setup-java@v5 - with: - distribution: ${{ env.JAVA_DISTRIBUTION }} - java-version: ${{ env.SDKMANRC_java }} - - name: "🐘 Setup Gradle" - uses: gradle/actions/setup-gradle@v5 - name: "⚙️ Run post-release" uses: apache/grails-github-actions/post-release@asf From acffb2621427747e83290613ed646bc0f2f4548f Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 15:03:44 +0100 Subject: [PATCH 40/47] docs: update readme and other md files --- .skills/example-apps.md | 34 ++++++++-------- .skills/gradle-best-practices.md | 65 ++++++++++++++++--------------- .skills/plugin-project.md | 56 ++++++++++++--------------- .skills/repository-structure.md | 58 ++++++++++++++-------------- AGENTS.md | 66 ++++++++++++++++---------------- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 8 ++-- README.md | 35 +++++++++-------- 8 files changed, 161 insertions(+), 163 deletions(-) diff --git a/.skills/example-apps.md b/.skills/example-apps.md index 2a92dbb..b8cd07a 100644 --- a/.skills/example-apps.md +++ b/.skills/example-apps.md @@ -47,14 +47,14 @@ The `examples/` directory can contain more than one app. Different apps can test All apps under `examples/` are auto-discovered by `settings.gradle`: ```groovy -def examples = file('examples').list() +def examples = file('examples').listFiles({ it.directory } as FileFilter) examples.each { example -> - include example - project(":$example").projectDir = file("examples/$example") + include example.name + project(":$example.name").projectDir = file("examples/$example.name") } ``` -New apps are also automatically included in coverage aggregation -- `coverage/build.gradle` discovers all example apps +New apps are also automatically included in coverage aggregation -- `code-coverage/build.gradle` discovers all example apps under `examples/` at configuration time, so no manual registration is needed. ## Project Structure @@ -90,9 +90,7 @@ Example apps apply convention plugins and declare their own dependencies: ```groovy plugins { - id 'org.grails.plugins.servertiming.compile' - id 'org.grails.plugins.servertiming.testing' - id 'org.grails.plugins.servertiming.example' + id 'config.example-app' } version = projectVersion @@ -119,7 +117,7 @@ dependencies { Key patterns: -- Apply `compile`, `testing`, and `example` convention plugins +- Apply `example-app` convention plugin - Depend on the plugin via `project(':grails-server-timing')` - NEVER apply `project-publish` -- example apps are not published - NEVER apply `plugin` -- example apps are applications, not plugins @@ -159,7 +157,7 @@ class ServerTimingIntegrationSpec extends Specification { ### What to test in integration tests - HTTP headers are present and correctly formatted -- Timing values are within expected ranges (e.g., slow action >= 200ms) +- Timing values are within expected ranges (e.g., slow action >= 200 ms) - Different response types (GSP views, JSON, plain text) all include headers - Static assets include `other`/`total` metrics but not `action`/`view` - Header format matches the W3C Server Timing specification @@ -169,10 +167,10 @@ class ServerTimingIntegrationSpec extends Specification { ### Integration test patterns 1. **Use `RestTemplate` or similar HTTP client** -- test real HTTP round-trips -2. **Verify headers, not internals** -- assert on `Server-Timing` header values, not internal class state +2. **Verify headers, not internals** – assert on `Server-Timing` header values, not internal class state 3. **Use timing thresholds, not exact values** -- assert `>= 200ms`, never `== 203ms` -4. **Test edge cases** -- static assets, JSON responses, redirects, errors -5. **Extract helper methods** -- centralize header parsing (e.g., `extractDuration()`) +4. **Test edge cases** – static assets, JSON responses, redirects, errors +5. **Extract helper methods** – centralize header parsing (e.g., `extractDuration()`) ### Test organization @@ -185,12 +183,12 @@ class ServerTimingIntegrationSpec extends Specification { Example apps should include purpose-built controllers and views that exercise the plugin's features: -- **Fast actions** -- verify baseline header presence -- **Slow actions** (with `Thread.sleep()`) -- verify timing accuracy -- **Variable delay actions** -- parameterized timing tests -- **Slow views** (GSP with embedded sleep) -- verify view timing separation -- **JSON/text responses** -- verify non-GSP response types -- **Multiple operations** -- verify timing accumulation +- **Fast actions** – verify baseline header presence +- **Slow actions** (with `Thread.sleep()`) – verify timing accuracy +- **Variable delay actions** – parameterized timing tests +- **Slow views** (GSP with embedded sleep) – verify view timing separation +- **JSON/text responses** – verify non-GSP response types +- **Multiple operations** – verify timing accumulation These are test fixtures that live in the example app, NOT in the plugin project. diff --git a/.skills/gradle-best-practices.md b/.skills/gradle-best-practices.md index 392bd10..bd3fd93 100644 --- a/.skills/gradle-best-practices.md +++ b/.skills/gradle-best-practices.md @@ -3,7 +3,7 @@ ## Purpose This skill covers Gradle best practices for this project, including convention plugins, extension configuration, -lazy APIs, and build structure. Convention plugins eliminate duplication across subprojects by centralizing shared +lazy APIs, and build structure. Convention plugins remove duplication across subprojects by centralizing shared build logic. They live in the `build-logic/` composite build and are applied by ID in each subproject's `build.gradle`. ## Core Rules @@ -11,7 +11,7 @@ build logic. They live in the `build-logic/` composite build and are applied by ### NEVER configure subprojects from the root build.gradle The root `build.gradle` must NEVER use `subprojects {}`, `allprojects {}`, or `configure(subprojects.matching {...}) {}` -to apply plugins or configure subproject behavior. This is an anti-pattern that causes ordering issues, breaks project +to apply plugins or configure subproject behavior. This is an antipattern that causes ordering issues, breaks project isolation, and makes builds harder to reason about. ```groovy @@ -34,7 +34,7 @@ allprojects { Instead, create a convention plugin in `build-logic/` and apply it in each subproject that needs it: ```groovy -// GOOD - build-logic/src/main/groovy/org.grails.plugins.servertiming.compile.gradle +// GOOD - build-logic/src/main/groovy/config.compile.gradle plugins { id 'groovy' } @@ -44,7 +44,7 @@ plugins { ```groovy // GOOD - plugin/build.gradle plugins { - id 'org.grails.plugins.servertiming.compile' + id 'config.compile' } ``` @@ -69,12 +69,12 @@ pluginManagement { Convention plugin files follow the pattern: ``` -build-logic/src/main/groovy/org.grails.plugins.servertiming..gradle +build-logic/src/main/groovy/config..gradle ``` The plugin ID matches the filename (minus the `.gradle` extension). For example: -- `org.grails.plugins.servertiming.compile.gradle` -> plugin ID `org.grails.plugins.servertiming.compile` +- `config.compile.gradle` -> plugin ID `config.compile` ### Declare external plugin dependencies in build-logic/build.gradle @@ -102,15 +102,19 @@ The `build-logic/build.gradle` reads the root `gradle.properties` and exposes th convention plugins can reference them (e.g., `grailsVersion`): ```groovy -file('../gradle.properties').withInputStream { - Properties props = new Properties() - props.load(it) - project.ext.gradleProperties = props -} - -allprojects { - for (String key : gradleProperties.stringPropertyNames()) { - ext.set(key, gradleProperties.getProperty(key)) +file('../gradle.properties').withInputStream { is -> + extensions.extraProperties.set( + 'gradleProperties', + new Properties().tap { load(is) } + ) +} + +allprojects { project -> + gradleProperties.stringPropertyNames().each { key -> + project.extensions.extraProperties.set( + key, + gradleProperties.getProperty(key) + ) } } ``` @@ -201,26 +205,27 @@ Convention plugins should compose by applying other convention plugins rather th plugins { id 'org.apache.grails.gradle.grails-web' id 'org.apache.grails.gradle.grails-gsp' - id 'org.grails.plugins.servertiming.assets' - id 'org.grails.plugins.servertiming.run' + id 'config.grails-assets' + id 'config.app-run' } ``` ## Existing Convention Plugins -| Plugin | Purpose | -|-------------------------------|-------------------------------------------------------------------------------------------------------| -| `compile.gradle` | Java/Groovy compilation: UTF-8, incremental, forked JVM, `-parameters`, Java release from `.sdkmanrc` | -| `testing.gradle` | Test framework: Spock, JUnit Platform, test-logger (mocha-parallel locally, plain-parallel in CI) | -| `plugin.gradle` | Applies `grails-plugin` profile, disables Spring dependency management | -| `example.gradle` | Applies grails-web, grails-gsp, assets, and run plugins for example apps | -| `project-publish.gradle` | Maven publishing metadata (artifact ID, license, developers, GitHub slug) | -| `root-publish.gradle` | Nexus publishing workaround (root-level only) | -| `docs.gradle` | Documentation aggregation (Groovydoc + Asciidoctor + GitHub Pages index) | -| `assets.gradle` | Asset pipeline with Bootstrap/jQuery/Bootstrap-Icons WebJars | -| `run.gradle` | Debug/debugWait JVM flags for `bootRun` | -| `coverage-aggregation.gradle` | JaCoCo coverage aggregation across subprojects (XML + HTML reports) | -| `style.gradle` | Checkstyle + CodeNarc code style checking; configs in `build-logic/config/` | +| Plugin | Purpose | +|----------------------------------|--------------------------------------------------------------------------------------| +| `app-run.gradle` | Debug flags for `bootRun` | +| `code-coverage.gradle` | JaCoCo coverage for project (XML + HTML reports) | +| `code-coverage-aggregate.gradle` | JaCoCo coverage aggregation across subprojects (XML + HTML reports) | +| `code-style.gradle` | Checkstyle + CodeNarc code style checking (configs in `build-logic/config/`) | +| `compile.gradle` | Java/Groovy compilation settings (UTF-8, incremental, Java release from `.sdkmanrc`) | +| `docs.gradle` | Documentation aggregation (Groovydoc + Asciidoctor) | +| `example-app.gradle` | Example app config (grails-web, GSP, assets) | +| `grails-assets.gradle` | Asset pipeline with Bootstrap/jQuery WebJars | +| `grails-plugin.gradle` | Grails plugin application | +| `publish.gradle` | Per-project Maven publishing metadata | +| `publish-root.gradle` | Root-level Nexus publishing workaround | +| `testing.gradle` | Test framework config (Spock, JUnit Platform, test-logger) | ## When to Create a New Convention Plugin diff --git a/.skills/plugin-project.md b/.skills/plugin-project.md index f5f276f..19453d4 100644 --- a/.skills/plugin-project.md +++ b/.skills/plugin-project.md @@ -27,7 +27,7 @@ The plugin project must NOT contain: Keeping integration/functional tests out of the plugin project ensures: -1. The plugin artifact is clean -- no test dependencies or test code leaks into the published JAR +1. The plugin artifact is clean – no test dependencies or test code leaks into the published JAR 2. Tests that require a running Grails application exercise the plugin as a real consumer would 3. The plugin's API surface is validated from the outside, not the inside 4. Different example apps can test different configurations of the plugin @@ -40,23 +40,26 @@ plugin/ ├── grails-app/ │ ├── conf/ │ │ ├── application.yml # Plugin-specific config defaults -│ │ └── logback-spring.xml # Logging config │ ├── controllers/ # Interceptors, controller-scoped artifacts │ │ └── org/grails/plugins/servertiming/ │ │ └── ServerTimingInterceptor.groovy -│ └── init/ # Plugin application class -│ └── org/grails/plugins/servertiming/ -│ └── Application.groovy └── src/ ├── main/groovy/ # Core plugin classes │ └── org/grails/plugins/servertiming/ - │ ├── GrailsServerTimingGrailsPlugin.groovy + │ ├── ServerTimingAutoConfiguration.groovy │ ├── ServerTimingFilter.groovy + │ ├── ServerTimingGrailsPlugin.groovy │ ├── ServerTimingResponseWrapper.groovy - │ ├── ServerTimingUtils.groovy + │ ├── config/ + │ │ ├── EnabledCondition.groovy + │ │ └── ServerTimingConfig.groovy │ └── core/ │ ├── Metric.groovy │ └── TimingMetric.groovy + ├── main/resources/ + │ ├── META-INF/spring + │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports + │ └── spring-configuration-metadata.json └── test/groovy/ # Unit tests ONLY └── org/grails/plugins/servertiming/ ├── MetricSpec.groovy @@ -69,25 +72,26 @@ The plugin's `build.gradle` should be minimal -- apply convention plugins and de ```groovy plugins { - id 'org.grails.plugins.servertiming.compile' - id 'org.grails.plugins.servertiming.testing' - id 'org.grails.plugins.servertiming.plugin' - id 'org.grails.plugins.servertiming.project-publish' + id 'config.compile' + id 'config.testing' + id 'config.grails-plugin' + id 'config.publish' } version = projectVersion -group = "org.grails.plugins" +group = 'org.grails.plugins' dependencies { + + profile 'org.apache.grails.profiles:web-plugin' + console 'org.apache.grails:grails-console' + compileOnly platform("org.apache.grails:grails-bom:$grailsVersion") compileOnly 'org.apache.grails:grails-dependencies-starter-web' - console "org.apache.grails:grails-console" - profile "org.apache.grails.profiles:web-plugin" - testImplementation platform("org.apache.grails:grails-bom:$grailsVersion") - testImplementation "org.apache.grails:grails-dependencies-starter-web" - testImplementation "org.apache.grails:grails-dependencies-test" + testImplementation 'org.apache.grails:grails-dependencies-starter-web' + testImplementation 'org.apache.grails:grails-dependencies-test' } ``` @@ -95,8 +99,8 @@ Key patterns: - Use `compileOnly` for framework dependencies the consuming application will provide - Use `testImplementation` for test-only dependencies -- Apply `project-publish` to configure Maven publishing metadata -- NEVER add custom task configuration here - move it to a convention plugin +- Apply `config.publish` to configure Maven publishing metadata +- NEVER add custom task configuration here – move it to a convention plugin ## Unit Test Guidelines @@ -127,18 +131,8 @@ Unit tests in the plugin project test individual classes in isolation: ## Plugin Descriptor -The `GrailsServerTimingGrailsPlugin` class extends `grails.plugins.Plugin` and registers Spring beans. It uses -`ServerTimingUtils` to check whether the plugin is enabled before registering the filter: - -```groovy -Closure doWithSpring() { - { -> - if (ServerTimingUtils.instance.isEnabled(grailsApplication)) { - // register filter beans - } - } -} -``` +The `ServerTimingGrailsPlugin` class extends `grails.plugins.Plugin` and exposes important +information about the plugin to the Grails framework. ## Dependency Scoping diff --git a/.skills/repository-structure.md b/.skills/repository-structure.md index 6078e60..060060d 100644 --- a/.skills/repository-structure.md +++ b/.skills/repository-structure.md @@ -13,6 +13,8 @@ grails-server-timing/ ├── .github/ # CI/CD workflows and GitHub config │ ├── workflows/ │ │ ├── ci.yml # Build, test, publish snapshots +│ │ ├── code-coverage.yml # Create a code coverage report +│ │ ├── code-style.yml # Check code style │ │ ├── release.yml # Multi-stage release pipeline │ │ └── release-notes.yml # Automated release draft notes │ ├── release-drafter.yml # Release drafter categories/labels @@ -26,24 +28,24 @@ grails-server-timing/ │ │ ├── checkstyle/ # Checkstyle XML configs │ │ └── codenarc/ # CodeNarc ruleset │ └── src/main/groovy/ # Convention plugin files (*.gradle) -│ ├── ...compile.gradle -│ ├── ...testing.gradle -│ ├── ...plugin.gradle -│ ├── ...example.gradle -│ ├── ...project-publish.gradle -│ ├── ...root-publish.gradle -│ ├── ...docs.gradle -│ ├── ...assets.gradle -│ ├── ...run.gradle -│ ├── ...coverage-aggregation.gradle -│ └── ...style.gradle +│ ├── config.app-run.gradle +│ ├── config.code-coverage.gradle +│ ├── config.code-coverage-aggregate.gradle +│ ├── config.code-style.gradle +│ ├── config.compile.gradle +│ ├── config.docs.gradle +│ ├── config.example-app.gradle +│ ├── config.grails-assets.gradle +│ ├── config.grails-plugin.gradle +│ ├── config.publish.gradle +│ ├── config.publish-root.gradle +│ └── config.testing.gradle │ ├── plugin/ # The Grails plugin artifact │ ├── build.gradle # Convention plugins + dependencies only │ ├── grails-app/ │ │ ├── conf/ # Plugin config (application.yml, logback) -│ │ ├── controllers/ # Interceptors and controller artifacts -│ │ └── init/ # Plugin Application class +│ │ └── controllers/ # Interceptors and controller artifacts │ └── src/ │ ├── main/groovy/ # Plugin source code │ └── test/groovy/ # Unit tests ONLY @@ -72,7 +74,7 @@ grails-server-timing/ │ └── src/ │ └── integration-test/ # Integration & functional tests │ -├── coverage/ # JaCoCo coverage aggregation +├── code-coverage/ # JaCoCo coverage aggregation │ └── build.gradle # Declares which projects contribute coverage data │ ├── docs/ # Asciidoctor documentation @@ -100,9 +102,9 @@ flows through convention plugins. ```groovy // Root build.gradle -- this is all that should be here plugins { - id "idea" - id 'org.grails.plugins.servertiming.docs' - id 'org.grails.plugins.servertiming.root-publish' + id 'idea' + id 'config.docs' + id 'config.root-publish' } ``` @@ -128,13 +130,13 @@ All tests requiring a running Grails application live in example apps under `exa Convention plugins in `build-logic/` eliminate all duplication: -- Compilation settings: `compile.gradle` -- Test configuration: `testing.gradle` -- Plugin setup: `plugin.gradle` -- Example app setup: `example.gradle` -- Publishing: `project-publish.gradle` -- Coverage aggregation: `coverage-aggregation.gradle` -- Code style checking: `style.gradle` +- Compilation settings: `config.compile.gradle` +- Test configuration: `config.testing.gradle` +- Plugin setup: `config.grails-plugin.gradle` +- Example app setup: `config.example-app.gradle` +- Publishing: `config.publish.gradle` +- Coverage aggregation: `config.coverage-aggregate.gradle` +- Code style checking: `config.code-style.gradle` ### 5. Centralized dependency resolution @@ -158,9 +160,7 @@ These are available in all subprojects as project properties (`projectVersion`, 2. Add a `build.gradle` applying the convention plugins: ```groovy plugins { - id 'org.grails.plugins.servertiming.compile' - id 'org.grails.plugins.servertiming.testing' - id 'org.grails.plugins.servertiming.example' + id 'config.example-app' } ``` 3. Add standard Grails app structure under `grails-app/` @@ -170,7 +170,7 @@ These are available in all subprojects as project properties (`projectVersion`, ## Adding a New Convention Plugin -1. Create a new file: `build-logic/src/main/groovy/org.grails.plugins.servertiming..gradle` +1. Create a new file: `build-logic/src/main/groovy/config..gradle` 2. If the plugin applies third-party plugins, add their dependencies to `build-logic/build.gradle` 3. Apply the new plugin ID in the relevant subproject(s) 4. Keep the plugin focused on a single concern @@ -188,7 +188,7 @@ These are available in all subprojects as project properties (`projectVersion`, ./gradlew :app1:integrationTest # Aggregated coverage report (unit + integration) -./gradlew :coverage:jacocoAggregatedReport +./gradlew jacocoAggregatedReport # Run an example app ./gradlew :app1:bootRun diff --git a/AGENTS.md b/AGENTS.md index 9f8b90b..5c13cd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,12 +16,12 @@ rendering time, and total request time, surfacing them in browser DevTools. Detailed best practices are documented in `.skills/`: -| Skill File | Purpose | -|--------------------------------------------------------------------------------|-------------------------------------------------------| -| [`.skills/repository-structure.md`](.skills/repository-structure.md) | Canonical directory layout and architectural rules | -| [`.skills/gradle-best-practices.md`](.skills/gradle-best-practices.md) | Gradle best practices, convention plugins, and idioms | -| [`.skills/plugin-project.md`](.skills/plugin-project.md) | Plugin project scope: source code + unit tests only | -| [`.skills/example-apps.md`](.skills/example-apps.md) | Example app patterns: integration & functional tests | +| Skill File | Purpose | +|------------------------------------------------------------------------|-------------------------------------------------------| +| [`.skills/repository-structure.md`](.skills/repository-structure.md) | Canonical directory layout and architectural rules | +| [`.skills/gradle-best-practices.md`](.skills/gradle-best-practices.md) | Gradle best practices, convention plugins, and idioms | +| [`.skills/plugin-project.md`](.skills/plugin-project.md) | Plugin project scope: source code + unit tests only | +| [`.skills/example-apps.md`](.skills/example-apps.md) | Example app patterns: integration & functional tests | **Read these skill files before making structural changes to the repository.** @@ -48,7 +48,7 @@ grails-server-timing/ │ └── src/test/ # Unit tests ONLY ├── examples/app1/ # Example Grails app │ └── src/integration-test/ # Integration & functional tests -├── coverage/ # JaCoCo coverage aggregation +├── code-coverage/ # JaCoCo coverage aggregation ├── docs/ # Asciidoctor documentation ├── build-logic/ # Gradle convention plugins (composite build) │ └── config/ # Code style configs (checkstyle, codenarc) @@ -71,7 +71,7 @@ grails-server-timing/ ./gradlew :app1:integrationTest # Aggregated coverage report (unit + integration) -./gradlew :coverage:jacocoAggregatedReport +./gradlew jacocoAggregatedReport # Skip tests ./gradlew build -PskipTests @@ -116,15 +116,14 @@ The plugin intercepts HTTP requests via a servlet filter and Grails interceptor: ### Core Classes (plugin/src/main/groovy/org/grails/plugins/servertiming/) -| Class | Purpose | -|----------------------------------|--------------------------------------------------------------------| -| `GrailsServerTimingGrailsPlugin` | Plugin descriptor; registers the filter bean when enabled | -| `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | -| `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | -| `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | -| `ServerTimingUtils` | Reads plugin configuration; auto-enables in DEV/TEST environments | -| `core/Metric` | Single timing metric model with RFC 7230 name validation | -| `core/TimingMetric` | Collection of metrics; generates header value | +| Class | Purpose | +|-------------------------------|--------------------------------------------------------------------| +| `ServerTimingGrailsPlugin` | Plugin descriptor; registers the filter bean when enabled | +| `ServerTimingFilter` | Servlet filter; creates `TimingMetric` per request, wraps response | +| `ServerTimingResponseWrapper` | Response wrapper; injects `Server-Timing` header on commit | +| `ServerTimingInterceptor` | Grails interceptor; tracks action and view timing | +| `core/Metric` | Single timing metric model with RFC 7230 name validation | +| `core/TimingMetric` | Collection of metrics; generates header value | ## Configuration @@ -157,19 +156,20 @@ Tests use the **Spock Framework** and run on JUnit Platform. Convention plugins in `build-logic/src/main/groovy/` standardize build configuration: -| Plugin | Purpose | -|-------------------------------|--------------------------------------------------------------------------------------| -| `compile.gradle` | Java/Groovy compilation settings (UTF-8, incremental, Java release from `.sdkmanrc`) | -| `testing.gradle` | Test framework config (Spock, JUnit Platform, test-logger) | -| `plugin.gradle` | Grails plugin application | -| `example.gradle` | Example app config (grails-web, GSP, assets) | -| `project-publish.gradle` | Per-project Maven publishing metadata | -| `root-publish.gradle` | Root-level Nexus publishing workaround | -| `docs.gradle` | Documentation aggregation (Groovydoc + Asciidoctor) | -| `assets.gradle` | Asset pipeline with Bootstrap/jQuery WebJars | -| `run.gradle` | Debug flags for `bootRun` | -| `coverage-aggregation.gradle` | JaCoCo coverage aggregation across subprojects (XML + HTML reports) | -| `style.gradle` | Checkstyle + CodeNarc code style checking (configs in `build-logic/config/`) | +| Plugin | Purpose | +|----------------------------------|--------------------------------------------------------------------------------------| +| `app-run.gradle` | Debug flags for `bootRun` | +| `code-coverage.gradle` | JaCoCo coverage for project (XML + HTML reports) | +| `code-coverage-aggregate.gradle` | JaCoCo coverage aggregation across subprojects (XML + HTML reports) | +| `code-style.gradle` | Checkstyle + CodeNarc code style checking (configs in `build-logic/config/`) | +| `compile.gradle` | Java/Groovy compilation settings (UTF-8, incremental, Java release from `.sdkmanrc`) | +| `docs.gradle` | Documentation aggregation (Groovydoc + Asciidoctor) | +| `example-app.gradle` | Example app config (grails-web, GSP, assets) | +| `grails-assets.gradle` | Asset pipeline with Bootstrap/jQuery WebJars | +| `grails-plugin.gradle` | Grails plugin application | +| `publish.gradle` | Per-project Maven publishing metadata | +| `publish-root.gradle` | Root-level Nexus publishing workaround | +| `testing.gradle` | Test framework config (Spock, JUnit Platform, test-logger) | ## CI/CD @@ -186,9 +186,9 @@ Convention plugins in `build-logic/src/main/groovy/` standardize build configura - Groovy source files use standard Grails conventions (domain classes, controllers, interceptors, services in `grails-app/`, other classes in `src/main/groovy/`). -- **Use `def` for local variables** where the type is inferred from the right-hand side (e.g., constructor calls, casts, - factory methods). Explicit types should only be used for local variables when the type cannot be inferred or when - needed for `@CompileStatic` compilation. This applies to both production code and tests. +- **Use `def` for local variables** where the type is inferred from the right-hand side (e.g., constructor calls, + method calls, casts, factory methods). Explicit types should only be used for local variables when the type cannot + be inferred or when needed for `@CompileStatic` compilation. This applies to both production code and tests. - Metric names must conform to RFC 7230 token rules (alphanumeric plus `!#$%&'*+-.^_`|~`). - Description strings follow HTTP quoted-string escaping rules. - The plugin uses `System.nanoTime()` for timing precision. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 7e3dc45..4231310 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -33,7 +33,7 @@ We strive to: - Sharing private communications without consent - Personal insults - Unwelcome sexual attention - - Repeated harassment -- if someone asks you to stop, then stop + - Repeated harassment – if someone asks you to stop, then stop - Advocating for or encouraging any of the above behavior 6. **Be concise.** Respect others' time. Write clearly so conversations stay productive. When a long explanation is diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfb0289..d49c336 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ sdk env install grails-server-timing/ ├── plugin/ # The publishable Grails plugin (source + unit tests ONLY) ├── examples/app1/ # Example app with integration tests -├── coverage/ # JaCoCo coverage aggregation +├── code-coverage/ # JaCoCo coverage aggregation ├── build-logic/ # Gradle convention plugins (shared build configuration) ├── docs/ # Asciidoctor documentation └── .skills/ # AI agent best-practice docs @@ -45,7 +45,7 @@ grails-server-timing/ Key architectural rules: -- **Plugin module** contains only plugin source code and unit tests -- no integration tests, no example controllers. +- **Plugin module** contains only plugin source code and unit tests – no integration tests, no example controllers. - **Example apps** under `examples/` host all integration and functional tests. They depend on the plugin as a real consumer would. - **Convention plugins** in `build-logic/` deduplicate build configuration. Never use `subprojects {}`, @@ -82,14 +82,14 @@ The project uses JaCoCo to aggregate coverage data from both plugin unit tests a ```bash # Generate the aggregated coverage report -./gradlew :coverage:jacocoAggregatedReport +./gradlew jacocoAggregatedReport ``` Reports are generated at: | Report | Location | |---------------------------------|----------------------------------------------------------------------------------| -| Aggregated (unit + integration) | `coverage/build/reports/jacoco/jacocoAggregatedReport/html/index.html` | +| Aggregated (unit + integration) | `code-coverage/build/reports/jacoco/jacocoAggregatedReport/html/index.html` | | Plugin unit tests | `plugin/build/reports/jacoco/test/html/index.html` | | App1 integration tests | `examples/app1/build/reports/jacoco/jacocoIntegrationTestReport/html/index.html` | diff --git a/README.md b/README.md index 7bff92b..a587372 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ -# Grails Server Timing Plugin +# 🧩 Grails Server Timing Plugin -[![CI](https://github.com/grails-plugins/grails-server-timing/actions/workflows/ci.yml/badge.svg)](https://github.com/grails-plugins/grails-server-timing/actions/workflows/ci.yml) -[![Coverage](https://github.com/grails-plugins/grails-server-timing/actions/workflows/coverage.yml/badge.svg)](https://github.com/grails-plugins/grails-server-timing/actions/workflows/coverage.yml) [![Maven Central](https://img.shields.io/maven-central/v/org.grails.plugins/grails-server-timing)](https://central.sonatype.com/artifact/org.grails.plugins/grails-server-timing) [![License](https://img.shields.io/github/license/grails-plugins/grails-server-timing)](https://www.apache.org/licenses/LICENSE-2.0) +[![CI](https://github.com/grails-plugins/grails-server-timing/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/grails-plugins/grails-server-timing/actions/workflows/ci.yml) +[![Coverage](https://github.com/grails-plugins/grails-server-timing/actions/workflows/coverage.yml/badge.svg?event=push)](https://github.com/grails-plugins/grails-server-timing/actions/workflows/coverage.yml) -A Grails plugin that injects [Server Timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing) HTTP headers into -responses, implementing the [W3C Server Timing specification](https://w3c.github.io/server-timing/). It automatically -tracks controller action time, view rendering time, and total request time -- surfacing them directly in your browser's -DevTools. +A Grails plugin that injects [Server Timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing) +HTTP headers into responses, implementing the [W3C Server Timing specification](https://w3c.github.io/server-timing/). +It automatically tracks controller action time, view rendering time, and total request time – surfacing them directly in +your browser's DevTools. + +## Documentation + +Full documentation is available at the [project documentation site](https://grails-plugins.github.io/grails-server-timing/). +This includes architecture details, the W3C specification, security considerations, and browser DevTools usage guides. ## Quick Start @@ -16,14 +21,15 @@ Add the dependency to your `build.gradle`: ```groovy dependencies { - implementation 'org.grails.plugins:grails-server-timing:0.0.1-SNAPSHOT' + implementation 'org.grails.plugins:grails-server-timing:' } ``` That's it. The plugin is **automatically enabled** in `development` and `test` environments. No additional configuration is required. -> **Note:** The plugin is disabled by default in production to prevent exposing timing data that could +> [!NOTE] +> The plugin is disabled by default in production to prevent exposing timing data that could > facilitate [timing attacks](https://w3c.github.io/server-timing/#security-considerations). ### Using Snapshot Builds @@ -52,11 +58,12 @@ Then reference the snapshot version in your `build.gradle`: ```groovy dependencies { - implementation 'org.grails.plugins:grails-server-timing:0.0.1-SNAPSHOT' + implementation 'org.grails.plugins:grails-server-timing:-SNAPSHOT' } ``` -> **Note:** Snapshot versions are unstable and may change without notice. They are intended for testing +> [!NOTE] +> Snapshot versions are unstable and may change without notice. They are intended for testing > upcoming changes before a release. ## How It Works @@ -120,12 +127,6 @@ environments: |----------------|--------|------|--------| | 0.x | 7.0.x | 17+ | 4.0.x | -## Documentation - -Full documentation is available at -the [project documentation site](https://grails-plugins.github.io/grails-server-timing/). This includes architecture -details, the W3C specification, security considerations, and browser DevTools usage guides. - ## Building from Source Prerequisites: [SDKMAN!](https://sdkman.io/) From d087969a4b969ba8e399bf5eba9935a243a4c880 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 15:04:10 +0100 Subject: [PATCH 41/47] build: add `org.junit.jupiter:junit-jupiter-api` for Gradle 9 --- build-logic/src/main/groovy/config.testing.gradle | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build-logic/src/main/groovy/config.testing.gradle b/build-logic/src/main/groovy/config.testing.gradle index 667d35b..fc1af94 100644 --- a/build-logic/src/main/groovy/config.testing.gradle +++ b/build-logic/src/main/groovy/config.testing.gradle @@ -43,3 +43,10 @@ tasks.withType(Test).configureEach { showCauses = true } } + +pluginManager.withPlugin('groovy') { + project.dependencies.add( + 'testRuntimeOnly', + 'org.junit.jupiter:junit-jupiter-api' + ) +} From 0787a2712dd7dc18e8443453a53d957a8dbc4948 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 15:04:28 +0100 Subject: [PATCH 42/47] fix: remove redundant plugin Application class --- .../plugins/servertiming/Application.groovy | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy diff --git a/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy b/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy deleted file mode 100644 index b8f0dff..0000000 --- a/plugin/grails-app/init/org/grails/plugins/servertiming/Application.groovy +++ /dev/null @@ -1,15 +0,0 @@ -package org.grails.plugins.servertiming - -import grails.boot.GrailsApp -import grails.boot.config.GrailsAutoConfiguration -import grails.plugins.metadata.PluginSource -import groovy.transform.CompileStatic - -@PluginSource -@CompileStatic -class Application extends GrailsAutoConfiguration { - - static void main(String[] args) { - GrailsApp.run(Application, args) - } -} From 5154fe7be905439042e6f1ee54f13b42d4815cd7 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 15:12:33 +0100 Subject: [PATCH 43/47] docs: update titles in readme --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a587372..3e59fc7 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ HTTP headers into responses, implementing the [W3C Server Timing specification]( It automatically tracks controller action time, view rendering time, and total request time – surfacing them directly in your browser's DevTools. -## Documentation +## 📖 Documentation Full documentation is available at the [project documentation site](https://grails-plugins.github.io/grails-server-timing/). This includes architecture details, the W3C specification, security considerations, and browser DevTools usage guides. -## Quick Start +## 🚀 Quick Start Add the dependency to your `build.gradle`: @@ -66,7 +66,7 @@ dependencies { > Snapshot versions are unstable and may change without notice. They are intended for testing > upcoming changes before a release. -## How It Works +## ❔ How It Works The plugin intercepts HTTP requests using a servlet filter and a Grails interceptor: @@ -87,7 +87,7 @@ Server-Timing: total;dur=156.3;desc="Total", action;dur=45.2;desc="Action", view | Controller with render (JSON, text) | `total`, `action` | | Static assets / other resources | `total`, `other` | -## Viewing in Browser DevTools +## 🌐 Viewing in Browser DevTools Open DevTools (F12), go to the **Network** tab, click a request, and select the **Timing** tab. Metrics appear under "Server Timing": @@ -96,7 +96,7 @@ appear under "Server Timing": - **Firefox** 61+ - **Safari** 16.4+ -## Configuration +## ⚙️ Configuration Configure in `application.yml` under `grails.plugins.servertiming`: @@ -121,13 +121,13 @@ environments: enabled: false ``` -## Compatibility +## 🤝 Compatibility | Plugin Version | Grails | Java | Groovy | |----------------|--------|------|--------| | 0.x | 7.0.x | 17+ | 4.0.x | -## Building from Source +## 🔨 Building from Source Prerequisites: [SDKMAN!](https://sdkman.io/) @@ -138,10 +138,10 @@ sdk env install # Install Java 17, Gradle 8.14, Groovy 4.0 See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development setup. -## Contributing +## 💡 Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a pull request. -## License +## 📜 License This project is licensed under the [Apache License 2.0](LICENSE). From 18b05651c15006a382285b13fbbf24f9df16876a Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 15:54:44 +0100 Subject: [PATCH 44/47] build: update code coverage aggregation --- .../groovy/config.code-coverage-aggregate.gradle | 13 ++++++++++++- code-coverage/build.gradle | 11 ----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle index 595e563..d91766b 100644 --- a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle +++ b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle @@ -10,9 +10,20 @@ extensions.configure(JacocoPluginExtension) { // Configuration for declaring which projects contribute coverage data. def coverageDataProjects = configurations.register('coverageDataProjects') { canBeConsumed = false - canBeResolved = true + canBeResolved = false } +def aggregateProject = project +rootProject.subprojects { sub -> + sub.pluginManager.withPlugin('config.code-coverage') { + aggregateProject.dependencies.add( + 'coverageDataProjects', + project(sub.path) + ) + } +} + + // Lazily collect source directories and class files from all coverageDataProjects dependencies. def covProjectList = coverageDataProjects.map { it.dependencies.withType(ProjectDependency).collect { diff --git a/code-coverage/build.gradle b/code-coverage/build.gradle index f8adea9..4b062a8 100644 --- a/code-coverage/build.gradle +++ b/code-coverage/build.gradle @@ -1,14 +1,3 @@ plugins { id 'config.code-coverage-aggregate' } - -dependencies { - // The plugin project (always included) - coverageDataProjects project(':grails-server-timing') - - // Auto-discover all example apps under examples/ - rootDir.toPath().resolve('examples').toFile() - .listFiles({ it.directory } as FileFilter) - .each { coverageDataProjects project(":$it.name") - } -} From 6dafe3112a3199840092af9607e79e5aafb30f15 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 16:35:36 +0100 Subject: [PATCH 45/47] build: update code coverage aggregation --- .../config.code-coverage-aggregate.gradle | 64 ++++++------------- 1 file changed, 19 insertions(+), 45 deletions(-) diff --git a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle index d91766b..2f74414 100644 --- a/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle +++ b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle @@ -7,8 +7,7 @@ extensions.configure(JacocoPluginExtension) { it.toolVersion = jacocoVersion } -// Configuration for declaring which projects contribute coverage data. -def coverageDataProjects = configurations.register('coverageDataProjects') { +def aggregateConfiguration = configurations.register('aggregateConfiguration') { canBeConsumed = false canBeResolved = false } @@ -17,54 +16,37 @@ def aggregateProject = project rootProject.subprojects { sub -> sub.pluginManager.withPlugin('config.code-coverage') { aggregateProject.dependencies.add( - 'coverageDataProjects', - project(sub.path) + 'aggregateConfiguration', + aggregateProject.dependencies.project(path: sub.path) ) } } - -// Lazily collect source directories and class files from all coverageDataProjects dependencies. -def covProjectList = coverageDataProjects.map { - it.dependencies.withType(ProjectDependency).collect { - project.project(it.path) - } +def coverageProjects = aggregateConfiguration.map { + it.dependencies.withType(ProjectDependency).collect { project.project(it.path) } } -def allSourceDirs = covProjectList.map { - it.findAll { it.plugins.hasPlugin('java') } - .collectMany { - it.extensions.getByType(SourceSetContainer).named('main').get() +def allSourceDirs = coverageProjects.map { + it.findAll { it.plugins.hasPlugin(JavaPlugin) } + .collectMany { p -> + p.extensions.getByType(SourceSetContainer).named('main').get() .allSource.sourceDirectories.files } } -def allClassDirs = covProjectList.map { - it.findAll { it.plugins.hasPlugin('java') } - .collectMany { - it.extensions.getByType(SourceSetContainer).named('main').get() +def allClassDirs = coverageProjects.map { + it.findAll { it.plugins.hasPlugin(JavaPlugin) } + .collectMany { p -> + p.extensions.getByType(SourceSetContainer).named('main').get() .output.files } } -def allExecFiles = covProjectList.map { - it.collectMany { - it.fileTree(it.layout.buildDirectory.dir('jacoco')) { - include('**/*.exec') - }.files - } -} - -// Register the aggregated coverage report task. -// This merges JaCoCo execution data from all coverageDataProjects into a single report. -// Task dependencies on all Test tasks (test, integrationTest, etc.) in the declared -// projects are derived automatically — no hard-coded project paths needed. -tasks.register('jacocoAggregatedReport', JacocoReport) { +def jacocoAggregatedReport = tasks.register('jacocoAggregatedReport', JacocoReport) { description = 'Generates aggregated JaCoCo coverage report across all subprojects.' group = 'verification' classDirectories.from(allClassDirs) - executionData.from(allExecFiles) sourceDirectories.from(allSourceDirs) reports { @@ -74,22 +56,14 @@ tasks.register('jacocoAggregatedReport', JacocoReport) { } } -// After evaluation, wire dependsOn for every Test task in every coverage project. -// This ensures all .exec files exist before the aggregated report collects them. -afterEvaluate { - def projects = coverageDataProjects.get().dependencies - .withType(ProjectDependency) - .collect { project.project(it.path) } - - tasks.named('jacocoAggregatedReport') {reportTask -> - projects.each { - it.tasks.withType(Test).configureEach { testTask -> - reportTask.dependsOn(testTask) - } +coverageProjects.get().each { + it.tasks.withType(Test).configureEach { test -> + jacocoAggregatedReport.configure { JacocoReport report -> + report.executionData(test) } } } tasks.named('check') { - dependsOn('jacocoAggregatedReport') + dependsOn(jacocoAggregatedReport) } From 597bbbeb33dde79676dcd07375ec640c90749cdb Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 16:46:39 +0100 Subject: [PATCH 46/47] build: try and generalize publishing --- .../src/main/groovy/config.publish-root.gradle | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/build-logic/src/main/groovy/config.publish-root.gradle b/build-logic/src/main/groovy/config.publish-root.gradle index 65febe8..18fcdd9 100644 --- a/build-logic/src/main/groovy/config.publish-root.gradle +++ b/build-logic/src/main/groovy/config.publish-root.gradle @@ -4,13 +4,9 @@ version = projectVersion group = 'this.will.be.overridden' -def publishedProjects = [ - (project.name - '-root') -] - -subprojects { - if (name in publishedProjects) { +subprojects { sub -> + sub.pluginManager.withPlugin('config.publish') { // This has to be applied here in the root project due to the nexus plugin requirements - apply plugin: 'org.apache.grails.gradle.grails-publish' + sub.apply(plugin: 'org.apache.grails.gradle.grails-publish') } -} \ No newline at end of file +} From fc3f24381b61062ce25eda6e3cae2389362945be Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 24 Feb 2026 16:49:53 +0100 Subject: [PATCH 47/47] chore: add matrei as developer --- plugin/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugin/build.gradle b/plugin/build.gradle index 6da6ea7..1dbda13 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -35,5 +35,8 @@ extensions.configure(GrailsPublishExtension) { it.name = 'Grails Plugins' it.url = 'https://github.com/grails-plugins' } - it.developers = [jdaugherty: 'James Daugherty'] + it.developers = [ + jdaugherty: 'James Daugherty', + matrei: 'Mattias Reichel', + ] }