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/.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" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24cc760..d6d903f 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,12 +30,18 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v5 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" + - 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: > @@ -51,9 +59,10 @@ 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 @@ -70,28 +79,31 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v5 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" + - 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' 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 83% rename from .github/workflows/coverage.yml rename to .github/workflows/code-coverage.yml index aa04e7e..eebb2f1 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -1,4 +1,4 @@ -name: "Coverage" +name: "Code Coverage" on: push: branches: @@ -28,20 +28,26 @@ 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 --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 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-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 3b40fd4..35f521c 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: @@ -53,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: @@ -71,7 +79,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 @@ -85,7 +92,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: @@ -108,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 }} @@ -124,7 +137,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: @@ -147,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" @@ -162,32 +181,11 @@ 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 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 diff --git a/.skills/example-apps.md b/.skills/example-apps.md index cebd7c2..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 @@ -143,13 +141,13 @@ 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') + 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') } @@ -159,20 +157,20 @@ 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 +- Header format matches the W3C Server Timing specification - Plugin behavior under different controller patterns (fast, slow, variable delay) - Multiple operations accumulate timing correctly ### 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 new file mode 100644 index 0000000..bd3fd93 --- /dev/null +++ b/.skills/gradle-best-practices.md @@ -0,0 +1,254 @@ +# Gradle Best Practices + +## Purpose + +This skill covers Gradle best practices for this project, including convention plugins, extension configuration, +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 + +### 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 antipattern that causes ordering issues, breaks project +isolation, and makes builds harder to reason about. + +```groovy +// BAD - Never do this in root build.gradle +subprojects { + apply plugin: 'groovy' + dependencies { + implementation 'org.example:shared-lib:1.0' + } +} + +// BAD - Never do this either +allprojects { + repositories { + mavenCentral() + } +} +``` + +Instead, create a convention plugin in `build-logic/` and apply it in each subproject that needs it: + +```groovy +// GOOD - build-logic/src/main/groovy/config.compile.gradle +plugins { + id 'groovy' +} +// shared compilation config here +``` + +```groovy +// GOOD - plugin/build.gradle +plugins { + id 'config.compile' +} +``` + +The ONLY exception is the `root-publish.gradle` convention plugin, which exists solely as a workaround for a Nexus +publishing bug (https://github.com/gradle-nexus/publish-plugin/issues/310) that requires version/group to be set at the +root level. + +### Use the composite build pattern + +Convention plugins reside in `build-logic/`, which is included as a composite build via `settings.gradle`: + +```groovy +pluginManagement { + includeBuild('./build-logic') { + it.name = 'build-logic' + } +} +``` + +### Naming convention + +Convention plugin files follow the pattern: + +``` +build-logic/src/main/groovy/config..gradle +``` + +The plugin ID matches the filename (minus the `.gradle` extension). For example: + +- `config.compile.gradle` -> plugin ID `config.compile` + +### Declare external plugin dependencies in build-logic/build.gradle + +When a convention plugin applies a third-party plugin, that plugin must be declared as an `implementation` dependency in +`build-logic/build.gradle`: + +```groovy +// build-logic/build.gradle +plugins { + id 'groovy-gradle-plugin' +} + +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 'cloud.wondrify:asset-pipeline-gradle' + implementation 'org.apache.grails.gradle:grails-publish' +} +``` + +### Share properties from root gradle.properties + +The `build-logic/build.gradle` reads the root `gradle.properties` and exposes those values as extra properties so +convention plugins can reference them (e.g., `grailsVersion`): + +```groovy +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) + ) + } +} +``` + +## Avoid Eager Initialization + +Always use lazy/deferred APIs to avoid eagerly resolving tasks or configurations: + +```groovy +// GOOD - lazy task configuration +tasks.withType(JavaCompile).configureEach { + options.encoding = StandardCharsets.UTF_8.name() +} + +tasks.named('bootRun', JavaExec) { + doFirst { /* ... */ } +} + +tasks.register('docs') { + dependsOn(/* ... */) +} + +// BAD - eager resolution +tasks.withType(JavaCompile) { // missing .configureEach + options.encoding = 'UTF-8' +} + +task docs { // old task() API is eager + dependsOn /* ... */ +} +``` + +Key APIs to use: + +- `tasks.register()` instead of `task()` +- `tasks.named()` instead of `tasks.getByName()` +- `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) +- 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 `it` for type hints and better IDE support: + +```groovy +// GOOD - explicit it in extensions.configure() for type hints +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'] +} +``` + +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') } +} + +tasks.named('bootRun', JavaExec) { + doFirst { + jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005") + } +} +``` + +**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 + +## Composition Over Inheritance + +Convention plugins should compose by applying other convention plugins rather than duplicating logic: + +```groovy +// example.gradle applies other convention plugins +plugins { + id 'org.apache.grails.gradle.grails-web' + id 'org.apache.grails.gradle.grails-gsp' + id 'config.grails-assets' + id 'config.app-run' +} +``` + +## Existing Convention Plugins + +| 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 + +Create a new convention plugin when: + +- Two or more subprojects share the same build configuration +- A subproject's `build.gradle` grows beyond applying plugins and declaring dependencies +- You need to enforce a project-wide standard (e.g., code formatting, static analysis) + +Keep each convention plugin focused on a single concern. Prefer small, composable plugins over monolithic ones. + +## Repository Management + +Repositories are managed centrally in `settings.gradle` via `dependencyResolutionManagement`: + +```groovy +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + maven { url = 'https://repo.grails.org/grails/restricted' } + } +} +``` + +This prevents subprojects from declaring their own repositories, ensuring consistency. The `FAIL_ON_PROJECT_REPOS` mode +enforces this. diff --git a/.skills/gradle-convention-plugins.md b/.skills/gradle-convention-plugins.md deleted file mode 100644 index cc98709..0000000 --- a/.skills/gradle-convention-plugins.md +++ /dev/null @@ -1,207 +0,0 @@ -# Gradle Convention Plugins 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`. - -## Core Rules - -### 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 -isolation, and makes builds harder to reason about. - -```groovy -// BAD - Never do this in root build.gradle -subprojects { - apply plugin: 'groovy' - dependencies { - implementation 'org.example:shared-lib:1.0' - } -} - -// BAD - Never do this either -allprojects { - repositories { - mavenCentral() - } -} -``` - -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 -plugins { - id 'groovy' -} -// shared compilation config here -``` - -```groovy -// GOOD - plugin/build.gradle -plugins { - id 'org.grails.plugins.servertiming.compile' -} -``` - -The ONLY exception is the `root-publish.gradle` convention plugin, which exists solely as a workaround for a Nexus -publishing bug (https://github.com/gradle-nexus/publish-plugin/issues/310) that requires version/group to be set at the -root level. - -### Use the composite build pattern - -Convention plugins reside in `build-logic/`, which is included as a composite build via `settings.gradle`: - -```groovy -pluginManagement { - includeBuild('./build-logic') { - it.name = 'build-logic' - } -} -``` - -### Naming convention - -Convention plugin files follow the pattern: - -``` -build-logic/src/main/groovy/org.grails.plugins.servertiming..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` - -### Declare external plugin dependencies in build-logic/build.gradle - -When a convention plugin applies a third-party plugin, that plugin must be declared as an `implementation` dependency in -`build-logic/build.gradle`: - -```groovy -// build-logic/build.gradle -plugins { - id 'groovy-gradle-plugin' -} - -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 'cloud.wondrify:asset-pipeline-gradle' - implementation 'org.apache.grails.gradle:grails-publish' -} -``` - -### Share properties from root gradle.properties - -The `build-logic/build.gradle` reads the root `gradle.properties` and exposes those values as extra properties so -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)) - } -} -``` - -## Avoid Eager Initialization - -Always use lazy/deferred APIs to avoid eagerly resolving tasks or configurations: - -```groovy -// GOOD - lazy task configuration -tasks.withType(JavaCompile).configureEach { - options.encoding = StandardCharsets.UTF_8.name() -} - -tasks.named('bootRun', JavaExec).configure { - doFirst { /* ... */ } -} - -tasks.register('docs') { - dependsOn = [/* ... */] -} - -// BAD - eager resolution -tasks.withType(JavaCompile) { // missing .configureEach - options.encoding = 'UTF-8' -} - -task docs { // old task() API is eager - dependsOn /* ... */ -} -``` - -Key APIs to use: - -- `tasks.register()` instead of `task()` -- `tasks.named()` instead of `tasks.getByName()` -- `tasks.withType(X).configureEach {}` instead of `tasks.withType(X) {}` -- `project.provider {}` for lazy values -- `layout.buildDirectory` instead of `buildDir` - -## Composition Over Inheritance - -Convention plugins should compose by applying other convention plugins rather than duplicating logic: - -```groovy -// example.gradle applies other convention plugins -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' -} -``` - -## 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/` | - -## When to Create a New Convention Plugin - -Create a new convention plugin when: - -- Two or more subprojects share the same build configuration -- A subproject's `build.gradle` grows beyond applying plugins and declaring dependencies -- You need to enforce a project-wide standard (e.g., code formatting, static analysis) - -Keep each convention plugin focused on a single concern. Prefer small, composable plugins over monolithic ones. - -## Repository Management - -Repositories are managed centrally in `settings.gradle` via `dependencyResolutionManagement`: - -```groovy -dependencyResolutionManagement { - repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS - repositories { - maven { url = 'https://repo.grails.org/grails/restricted' } - } -} -``` - -This prevents subprojects from declaring their own repositories, ensuring consistency. The `FAIL_ON_PROJECT_REPOS` mode -enforces this. 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 3fe8654..5c13cd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ ## 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. - **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 @@ -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-convention-plugins.md`](.skills/gradle-convention-plugins.md) | Convention plugin patterns, naming, and anti-patterns | -| [`.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 @@ -132,8 +131,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. @@ -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,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, + 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 648e15b..3e59fc7 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,35 @@ -# 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. -## Quick Start +## 📖 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 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 @@ -53,14 +58,15 @@ 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 +## ❔ How It Works The plugin intercepts HTTP requests using a servlet filter and a Grails interceptor: @@ -81,16 +87,16 @@ 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. 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+ - **Firefox** 61+ - **Safari** 16.4+ -## Configuration +## ⚙️ Configuration Configure in `application.yml` under `grails.plugins.servertiming`: @@ -106,28 +112,22 @@ environments: development: grails: plugins: - servertiming: + serverTiming: enabled: true production: grails: plugins: - servertiming: + serverTiming: enabled: false ``` -## Compatibility +## 🤝 Compatibility | Plugin Version | Grails | Java | Groovy | |----------------|--------|------|--------| | 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 +## 🔨 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). diff --git a/build-logic/build.gradle b/build-logic/build.gradle index 6022f1b..746607d 100644 --- a/build-logic/build.gradle +++ b/build-logic/build.gradle @@ -2,22 +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:4.0.0' 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' -} \ No newline at end of file + 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-run.gradle b/build-logic/src/main/groovy/config.app-run.gradle new file mode 100644 index 0000000..8363425 --- /dev/null +++ b/build-logic/src/main/groovy/config.app-run.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/config.code-coverage-aggregate.gradle b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle new file mode 100644 index 0000000..2f74414 --- /dev/null +++ b/build-logic/src/main/groovy/config.code-coverage-aggregate.gradle @@ -0,0 +1,69 @@ +plugins { + id 'base' + id 'jacoco' +} + +extensions.configure(JacocoPluginExtension) { + it.toolVersion = jacocoVersion +} + +def aggregateConfiguration = configurations.register('aggregateConfiguration') { + canBeConsumed = false + canBeResolved = false +} + +def aggregateProject = project +rootProject.subprojects { sub -> + sub.pluginManager.withPlugin('config.code-coverage') { + aggregateProject.dependencies.add( + 'aggregateConfiguration', + aggregateProject.dependencies.project(path: sub.path) + ) + } +} + +def coverageProjects = aggregateConfiguration.map { + it.dependencies.withType(ProjectDependency).collect { project.project(it.path) } +} + +def allSourceDirs = coverageProjects.map { + it.findAll { it.plugins.hasPlugin(JavaPlugin) } + .collectMany { p -> + p.extensions.getByType(SourceSetContainer).named('main').get() + .allSource.sourceDirectories.files + } +} + +def allClassDirs = coverageProjects.map { + it.findAll { it.plugins.hasPlugin(JavaPlugin) } + .collectMany { p -> + p.extensions.getByType(SourceSetContainer).named('main').get() + .output.files + } +} + +def jacocoAggregatedReport = tasks.register('jacocoAggregatedReport', JacocoReport) { + description = 'Generates aggregated JaCoCo coverage report across all subprojects.' + group = 'verification' + + classDirectories.from(allClassDirs) + sourceDirectories.from(allSourceDirs) + + reports { + xml.required = true + html.required = true + csv.required = false + } +} + +coverageProjects.get().each { + it.tasks.withType(Test).configureEach { test -> + jacocoAggregatedReport.configure { JacocoReport report -> + report.executionData(test) + } + } +} + +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 new file mode 100644 index 0000000..e5e0aab --- /dev/null +++ b/build-logic/src/main/groovy/config.code-coverage.gradle @@ -0,0 +1,57 @@ +plugins { + id 'jacoco' +} + +extensions.configure(JacocoPluginExtension) { + it.toolVersion = jacocoVersion +} + +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 + } + + 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/build-logic/src/main/groovy/config.code-style.gradle b/build-logic/src/main/groovy/config.code-style.gradle new file mode 100644 index 0000000..91179da --- /dev/null +++ b/build-logic/src/main/groovy/config.code-style.gradle @@ -0,0 +1,42 @@ +plugins { + id 'checkstyle' + id 'codenarc' +} + +// Resolved relative to the root project directory, which is the parent of build-logic/. +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 + it.configDirectory = checkstyleConfigDir + it.maxWarnings = 0 + it.showViolations = true + it.ignoreFailures = false +} + +tasks.withType(Checkstyle).configureEach { + group = 'verification' + onlyIf { !project.hasProperty('skipCodeStyle') } +} + +extensions.configure(CodeNarcExtension) { + it.toolVersion = codenarcVersion + it.configFile = codenarcConfigDir.file('codenarc.groovy').getAsFile() + it.maxPriority1Violations = 0 + it.maxPriority2Violations = 0 + it.maxPriority3Violations = 0 +} + +tasks.withType(CodeNarc).configureEach { + group = 'verification' + onlyIf { !project.hasProperty('skipCodeStyle') } +} + +tasks.register('codeStyle') { + group = 'verification' + description = 'Runs all code style checks (Checkstyle + 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/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/config.example-app.gradle b/build-logic/src/main/groovy/config.example-app.gradle new file mode 100644 index 0000000..ab33f6e --- /dev/null +++ b/build-logic/src/main/groovy/config.example-app.gradle @@ -0,0 +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/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 64% 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 index 65febe8..18fcdd9 100644 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.root-publish.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 +} 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/config.testing.gradle b/build-logic/src/main/groovy/config.testing.gradle new file mode 100644 index 0000000..fc1af94 --- /dev/null +++ b/build-logic/src/main/groovy/config.testing.gradle @@ -0,0 +1,52 @@ +import com.adarshr.gradle.testlogger.TestLoggerExtension + +plugins { + id 'com.adarshr.test-logger' +} + +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. +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 +} + +tasks.withType(Test).configureEach { + onlyIf { + !project.hasProperty('skipTests') + } + + useJUnitPlatform() + + maxHeapSize = '1g' // set to match the groovy compile task to ensure the worker daemons are reused + + reports { + junitXml.required = false + html.required = true + } + + testLogging { + showStandardStreams = false + exceptionFormat = 'full' + stackTraceFilters = ['groovy'] + events = ['failed', 'skipped', 'standardError'] + showStackTraces = true + showCauses = true + } +} + +pluginManager.withPlugin('groovy') { + project.dependencies.add( + 'testRuntimeOnly', + 'org.junit.jupiter:junit-jupiter-api' + ) +} 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.coverage-aggregation.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle deleted file mode 100644 index db71206..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.coverage-aggregation.gradle +++ /dev/null @@ -1,77 +0,0 @@ -plugins { - id 'base' - id 'jacoco' -} - -jacoco { - toolVersion = '0.8.12' -} - -// Configuration for declaring which projects contribute coverage data. -configurations { - 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 allSourceDirs = covProjectList.map { projects -> - projects.findAll { it.plugins.hasPlugin('java') } - .collectMany { it.sourceSets.main.allSource.sourceDirectories.files } -} - -def allClassDirs = covProjectList.map { projects -> - projects.findAll { it.plugins.hasPlugin('java') } - .collectMany { it.sourceSets.main.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() ?: [] - } -} - -// 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) { - group = 'verification' - description = 'Generates aggregated JaCoCo coverage report across all subprojects.' - - executionData.from(allExecFiles) - sourceDirectories.from(allSourceDirs) - classDirectories.from(allClassDirs) - - 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 = configurations.coverageDataProjects.dependencies - .withType(ProjectDependency) - .collect { project.project(it.path) } - - tasks.named('jacocoAggregatedReport') { - projects.each { prj -> - prj.tasks.withType(Test).each { testTask -> - dependsOn testTask - } - } - } -} - -tasks.named('check') { - dependsOn tasks.named('jacocoAggregatedReport') -} 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 82799af..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.example.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.example.gradle deleted file mode 100644 index 97ceefb..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.example.gradle +++ /dev/null @@ -1,6 +0,0 @@ -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' -} \ 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 7051a35..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 { - it.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 0797e21..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.run.gradle +++ /dev/null @@ -1,10 +0,0 @@ -tasks.named('bootRun', JavaExec).configure { - 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-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle b/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle deleted file mode 100644 index df9cec9..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.style.gradle +++ /dev/null @@ -1,42 +0,0 @@ -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') - -checkstyle { - toolVersion = checkstyleVersion - configDirectory = checkstyleConfigDir - maxWarnings = 0 - showViolations = true - ignoreFailures = false -} - -tasks.withType(Checkstyle).configureEach { - it.group = 'verification' - it.onlyIf { !project.hasProperty('skipCodeStyle') } -} - -codenarc { - toolVersion = codenarcVersion - configFile = new File(codenarcConfigDir, 'codenarc.groovy') - maxPriority1Violations = 0 - maxPriority2Violations = 0 - maxPriority3Violations = 0 -} - -tasks.withType(CodeNarc).configureEach { - it.group = 'verification' - it.onlyIf { !project.hasProperty('skipCodeStyle') } -} - -tasks.register('codeStyle').configure { - group = 'verification' - description = 'Runs all code style checks (Checkstyle + CodeNarc).' - dependsOn tasks.withType(Checkstyle) - dependsOn tasks.withType(CodeNarc) -} 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 deleted file mode 100644 index ed544c0..0000000 --- a/build-logic/src/main/groovy/org.grails.plugins.servertiming.testing.gradle +++ /dev/null @@ -1,87 +0,0 @@ -plugins { - id 'com.adarshr.test-logger' - id 'jacoco' -} - -boolean isCi = System.getenv('CI') != null - -// This configures the 'pretty' test logging -testlogger { - theme isCi ? 'plain-parallel' : 'mocha-parallel' - showExceptions true - showStandardStreams false - showSummary true - showPassed true - showSkipped true - showFailed true -} - -jacoco { - toolVersion = '0.8.12' -} - -tasks.withType(Test).configureEach { - onlyIf { - !project.hasProperty('skipTests') - } - - useJUnitPlatform() - - maxHeapSize = "1g" // set to match the groovy compile task to ensure the worker daemons are reused - - reports { - junitXml.required = false - html.required = true - } - - testLogging { - showStandardStreams = false - exceptionFormat = 'full' - stackTraceFilters = ['groovy'] - events = ['failed', 'skipped', 'standardError'] - showStackTraces = true - 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) { - dependsOn tasks.named('test') - - reports { - xml.required = true - html.required = true - csv.required = false - } -} - -// 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 { - 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 - executionData.from(execFile) - sourceSets(project.sourceSets.main) - - reports { - xml.required = true - html.required = true - csv.required = false - } - } - integrationTest.finalizedBy reportTask - } -} 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/code-coverage/build.gradle b/code-coverage/build.gradle new file mode 100644 index 0000000..4b062a8 --- /dev/null +++ b/code-coverage/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'config.code-coverage-aggregate' +} diff --git a/coverage/build.gradle b/coverage/build.gradle deleted file mode 100644 index 8c75d83..0000000 --- a/coverage/build.gradle +++ /dev/null @@ -1,13 +0,0 @@ -plugins { - id 'org.grails.plugins.servertiming.coverage-aggregation' -} - -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 { example -> - coverageDataProjects project(":$example") - } -} diff --git a/docs/build.gradle b/docs/build.gradle index 659fa5b..6027ac9 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -1,44 +1,45 @@ import org.asciidoctor.gradle.jvm.AsciidoctorTask plugins { - id "org.asciidoctor.jvm.convert" version "4.0.5" + id 'org.asciidoctor.jvm.convert' } 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/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..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.servertiming` namespace. +All configuration options are specified in your `application.yml` or `application.groovy` file under the `grails.plugins.serverTiming` namespace. ==== Enabling/Disabling the Plugin @@ -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,23 +62,23 @@ environments: development: grails: plugins: - servertiming: + serverTiming: enabled: true test: grails: plugins: - servertiming: + serverTiming: enabled: true production: grails: plugins: - servertiming: + serverTiming: enabled: false ---- === 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: @@ -89,7 +89,7 @@ environments: production: grails: plugins: - servertiming: + serverTiming: enabled: true ---- diff --git a/docs/src/docs/how-it-works.adoc b/docs/src/docs/how-it-works.adoc index e3d80d5..564399c 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,22 +66,46 @@ 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: +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/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/build.gradle b/examples/app1/build.gradle index efe5e92..dc6e54c 100644 --- a/examples/app1/build.gradle +++ b/examples/app1/build.gradle @@ -1,44 +1,47 @@ 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.example-app' } 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/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..fd614e3 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,25 +26,25 @@ 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') + def 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' - 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') + then: 'the response should have a Server Timing header' + 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') + then: 'the response should have a Server Timing header' + 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') + then: 'the response should have a Server Timing header' + 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') + then: 'the response should have a Server Timing header' + 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') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 150ms (sum of 50+75+25)' @@ -115,12 +115,12 @@ 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') + def response = doGet('/serverTimingTest/jsonResponse') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 50ms' @@ -128,12 +128,12 @@ 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') + def response = doGet('/serverTimingTest/textResponse') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the action timing should be at least 30ms' @@ -141,12 +141,12 @@ 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') + def response = doGet('/serverTimingTest/fast') - then: 'the Server-Timing header should follow the spec format' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the Server Timing header should follow the spec format' + def serverTiming = response.headers.getFirst('Server-Timing') // Header should contain metric name, duration, and description // Format: name;dur=X;desc="description" @@ -154,21 +154,21 @@ 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('/') + def response = doGet('/') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def 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') + def response = doGet('/assets/application.css?compile=false') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: "the header should contain 'other' metric (not action/view)" @@ -178,12 +178,12 @@ 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') + 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') + then: 'the final response (after following redirect) should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain action and view metrics from the target action' @@ -191,24 +191,24 @@ 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') + 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') + then: 'the final response should have a Server Timing header with total time' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'total should be present' 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') + def response = doGet('/serverTimingTest/forwardToTarget') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain action metrics' @@ -218,24 +218,24 @@ 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') + def response = doGet('/serverTimingTest/forwardToTarget') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain view metric since the target action renders a view' 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') + def response = doGet('/serverTimingTest/chainToTarget') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain action metrics' @@ -245,12 +245,12 @@ 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') + def response = doGet('/serverTimingTest/chainToTarget') - then: 'the response should have a Server-Timing header' - String serverTiming = response.headers.getFirst('Server-Timing') + then: 'the response should have a Server Timing header' + def serverTiming = response.headers.getFirst('Server-Timing') serverTiming != null and: 'the header should contain view metric since the chain target renders a view' @@ -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 */ @@ -273,4 +273,3 @@ class ServerTimingIntegrationSpec extends Specification { return null } } - diff --git a/examples/app2/build.gradle b/examples/app2/build.gradle index 5d5db40..661d543 100644 --- a/examples/app2/build.gradle +++ b/examples/app2/build.gradle @@ -1,44 +1,46 @@ 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.example-app' } 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/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/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..0035ef6 100644 --- a/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy +++ b/examples/app2/src/integration-test/groovy/app2/ServerTimingDisabledIntegrationSpec.groovy @@ -8,9 +8,9 @@ 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). + * (grails.plugins.serverTiming.enabled: false). */ @Integration class ServerTimingDisabledIntegrationSpec extends Specification { @@ -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') + def 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') + def 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') + def 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') + def 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('/') + def 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') + def 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/gradle.properties b/gradle.properties index 2331a26..e62c934 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,17 @@ 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 + +# 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 diff --git a/plugin/build.gradle b/plugin/build.gradle index 079310d..1dbda13 100644 --- a/plugin/build.gradle +++ b/plugin/build.gradle @@ -1,22 +1,42 @@ +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-coverage' + 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', + matrei: 'Mattias Reichel', + ] } 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 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..e79e7dc 100644 --- a/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy +++ b/plugin/grails-app/controllers/org/grails/plugins/servertiming/ServerTimingInterceptor.groovy @@ -1,8 +1,14 @@ package org.grails.plugins.servertiming -import grails.artefact.Interceptor -import grails.compiler.GrailsCompileStatic +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 org.grails.plugins.servertiming.config.EnabledCondition +import org.grails.plugins.servertiming.config.ServerTimingConfig import org.grails.plugins.servertiming.core.TimingMetric /** @@ -10,25 +16,26 @@ 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' - - 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.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.") } + + metricKey = config.metricKey } @Override boolean before() { - TimingMetric timing = request.getAttribute(metricKey) as TimingMetric + def timing = request.getAttribute(metricKey) as TimingMetric if (timing) { timing.create('action', 'Action') .start() @@ -43,7 +50,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/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) - } -} diff --git a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy deleted file mode 100644 index 8e08941..0000000 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/GrailsServerTimingGrailsPlugin.groovy +++ /dev/null @@ -1,94 +0,0 @@ -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. - * - *

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.

- * - *

Configuration

- *

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

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

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 GrailsServerTimingGrailsPlugin extends Plugin { - - /** Minimum Grails version required for this plugin */ - def grailsVersion = '7.0.7 > *' - - /** Plugin title */ - def title = 'grails-server-timing' - - /** Plugin author */ - def author = 'James Daugherty' - - /** Plugin description */ - 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/' - - /** Plugin license type */ - def license = 'APACHE' - - /** 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/ServerTimingAutoConfiguration.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingAutoConfiguration.groovy new file mode 100644 index 0000000..8739a32 --- /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.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/ServerTimingFilter.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingFilter.groovy index 945c909..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,26 +11,33 @@ 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. + * 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 @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 @@ -37,24 +45,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.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 } - 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 { @@ -75,4 +83,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 new file mode 100644 index 0000000..fc48547 --- /dev/null +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingGrailsPlugin.groovy @@ -0,0 +1,54 @@ +package org.grails.plugins.servertiming + +import groovy.util.logging.Slf4j + +import grails.plugins.Plugin + +/** + * 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, + * allowing developers to communicate backend server performance metrics to the browser.

+ * + *

Configuration

+ *

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

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

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 + */ +@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 = pluginName + + /** Plugin author */ + def author = 'James Daugherty' + + /** Plugin description */ + 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/' + + /** Plugin license type */ + def license = 'APACHE' + + /** Source control management information */ + def scm = [url: 'https://github.com/grails-plugins/grails-server-timing'] +} 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..ee9dcfa 100644 --- a/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy +++ b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingResponseWrapper.groovy @@ -6,17 +6,17 @@ 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 /** - * 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 @CompileStatic class ServerTimingResponseWrapper extends HttpServletResponseWrapper { + static String HEADER_NAME = 'Server-Timing' private final TimingMetric timing private final HttpServletResponse originalResponse private boolean headerAdded = false @@ -30,23 +30,23 @@ 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) { - log.debug('Adding {} header with timing metrics', ServerTimingInterceptor.HEADER_NAME) + log.debug('Adding {} header with timing metrics', HEADER_NAME) headerAdded = true stopTimings() - String headerValue = timing.toHeaderValue() - log.trace('{} header value: {}', ServerTimingInterceptor.HEADER_NAME, headerValue) + def headerValue = timing.toHeaderValue() + 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) } } @@ -58,13 +58,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() } @@ -86,8 +86,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 } @@ -191,5 +193,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/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/ServerTimingUtils.groovy deleted file mode 100644 index 9d97b2e..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/groovy/org/grails/plugins/servertiming/core/Metric.groovy b/plugin/src/main/groovy/org/grails/plugins/servertiming/core/Metric.groovy index 29a9873..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 @@ -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 */ @@ -49,7 +49,7 @@ class Metric implements Validateable, Serializable { } Metric start() { - if (duration) { + if (startTimeNanos || duration) { throw new IllegalStateException('The metric has already started.') } @@ -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 7508b4c..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 @@ -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 */ @@ -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/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 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" + } + ] +} 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..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() @@ -349,32 +349,32 @@ 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"() { 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 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') + 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"' @@ -447,12 +447,12 @@ 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') + 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/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 + } + } +} 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..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 @@ -269,12 +269,12 @@ 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() + 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') 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 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 {