diff --git a/.github/data/spring-boot-2-versions.json b/.github/data/spring-boot-2-versions.json new file mode 100644 index 00000000000..f59dd6cd432 --- /dev/null +++ b/.github/data/spring-boot-2-versions.json @@ -0,0 +1,9 @@ +{ + "versions": [ + "2.4.13", + "2.5.15", + "2.6.15", + "2.7.0", + "2.7.18" + ] +} diff --git a/.github/data/spring-boot-3-versions.json b/.github/data/spring-boot-3-versions.json new file mode 100644 index 00000000000..eb678e3e51a --- /dev/null +++ b/.github/data/spring-boot-3-versions.json @@ -0,0 +1,8 @@ +{ + "versions": [ + "3.2.12", + "3.3.13", + "3.4.13", + "3.5.13" + ] +} diff --git a/.github/data/spring-boot-4-versions.json b/.github/data/spring-boot-4-versions.json new file mode 100644 index 00000000000..22f38ec9bcc --- /dev/null +++ b/.github/data/spring-boot-4-versions.json @@ -0,0 +1,6 @@ +{ + "versions": [ + "4.0.0", + "4.0.5" + ] +} diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 48ed0a69665..f70e218f06b 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -15,13 +15,25 @@ concurrency: cancel-in-progress: true jobs: + load-versions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + - name: Set matrix data + id: set-matrix + run: echo "matrix=$(cat .github/data/spring-boot-2-versions.json | jq -c .versions)" >> $GITHUB_OUTPUT + spring-boot-2-matrix: + needs: load-versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + springboot-version: ${{ fromJSON(needs.load-versions.outputs.matrix) }} name: Spring Boot ${{ matrix.springboot-version }} env: diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 0e00608efe2..54bfbf42963 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -15,13 +15,25 @@ concurrency: cancel-in-progress: true jobs: + load-versions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + - name: Set matrix data + id: set-matrix + run: echo "matrix=$(cat .github/data/spring-boot-3-versions.json | jq -c .versions)" >> $GITHUB_OUTPUT + spring-boot-3-matrix: + needs: load-versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] + springboot-version: ${{ fromJSON(needs.load-versions.outputs.matrix) }} name: Spring Boot ${{ matrix.springboot-version }} env: diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index c6ae6195f59..5e0d43ddfc0 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -15,13 +15,25 @@ concurrency: cancel-in-progress: true jobs: + load-versions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout Repo + uses: actions/checkout@v5 + - name: Set matrix data + id: set-matrix + run: echo "matrix=$(cat .github/data/spring-boot-4-versions.json | jq -c .versions)" >> $GITHUB_OUTPUT + spring-boot-4-matrix: + needs: load-versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '4.0.0', '4.0.5' ] + springboot-version: ${{ fromJSON(needs.load-versions.outputs.matrix) }} name: Spring Boot ${{ matrix.springboot-version }} env: diff --git a/.github/workflows/update-spring-boot-versions.yml b/.github/workflows/update-spring-boot-versions.yml new file mode 100644 index 00000000000..36d48210368 --- /dev/null +++ b/.github/workflows/update-spring-boot-versions.yml @@ -0,0 +1,78 @@ +name: Update Spring Boot Versions + +on: + schedule: + # Run every Monday at 9:00 AM UTC + - cron: '0 9 * * 1' + workflow_dispatch: # Allow manual triggering + pull_request: # remove this before merging + +permissions: + contents: write + pull-requests: write + +jobs: + update-spring-boot-versions: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install requests packaging + + - name: Update Spring Boot versions + id: update_versions + run: python scripts/update-spring-boot-versions.py + + - name: Check for changes + id: changes + run: | + if git diff --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Create Pull Request + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: feat/spring-boot-matrix-auto-update + commit-message: "chore: Update Spring Boot version matrices" + title: "Automated Spring Boot Version Update" + body: | + ## Automated Spring Boot Version Update + + This PR updates the Spring Boot version matrices in our test workflows based on the latest available versions. + + ### Changes Made: + ${{ steps.update_versions.outputs.changes_summary || 'See diff for changes' }} + + ### Update Strategy: + - **Patch updates**: Updated to latest patch version of existing minor versions + - **New minor versions**: Added new minor versions and removed second oldest (keeping minimum supported) + - **Minimum version preserved**: Always keeps the minimum supported version for compatibility testing + + This ensures our CI tests stay current with Spring Boot releases while maintaining coverage of older versions that users may still be using. + branch: automated-spring-boot-version-update + delete-branch: true + draft: false + + - name: Summary + run: | + if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then + echo "✅ Spring Boot version updates found and PR created" + echo "${{ steps.update_versions.outputs.changes_summary }}" + else + echo "ℹ️ No Spring Boot version updates needed" + fi diff --git a/autoresearch-results-presentation.html b/autoresearch-results-presentation.html new file mode 100644 index 00000000000..290b6b58d97 --- /dev/null +++ b/autoresearch-results-presentation.html @@ -0,0 +1,626 @@ + + + + + + Autoresearch Attempts — Baseline-relative Measurements + + + +
+
+
Autoresearch results · baseline-relative breakdown
+

Patch, measurement, and rationale for every attempted change

+

Each attempt has a stable ID reused in navigation, measurements, and explanations. Measurements are medians from the autoresearch harness unless marked not measured. All deltas and percentages are computed against AR-00 baseline.

+
+
Best recorded allocation3,967,752B−120,984B / −2.96% vs baseline
+
Best recorded CPU47.970ms−12.036ms / −20.06% vs baseline
+
Best recorded wall18.248ms−4.747ms / −20.64% vs baseline
+
Output equivalence132envelopes every measured run
+
+

Patch note: kept attempts use actual commit diffs where available. Discarded attempts were reset by the autoresearch harness, so their patch blocks are labeled as reconstructed patch-style diffs when the exact uncommitted patch was no longer available.

+ +
+
+ +
+
+
+
AR-00 · reference
+

Baseline

+
+ baseline +
+
+
Allocation4,088,736B
+
CPU median60.006ms
+
Wall median22.995msRelay output equivalent unless noted
+
+
+

Why try this?

Establish the reference measurement for origin/main before trying allocation reductions.

+

What changed?

Measured the mixed JVM/core SDK workload with fake Relay. Baseline emitted 132 envelopes: 120 events, 6 logs, and 6 transactions.

+

Result

Reference point for all candidate comparisons. GC was 0 collections / 0.0ms.

+
+
+ Patch-style diff reference +
# No patch. Baseline commit: 5dee26ba7ca9931bca62b730b210e01063e1c2d1
+# Workload output: 132 envelopes (event:120, log:6, transaction:6).
+
+

↑ Back to attempt list

+
+
+
+
+
AR-01 · kept
+

Avoid Scope defensive copies + use HashMap in CombinedScopeView

+
+ kept +
+
+
Allocation4,042,104B−46,632B / −1.14% vs baseline
+
CPU median47.770ms−12.236ms / −20.39% vs baseline; passed
+
Wall median20.311ms−2.684ms / −11.67% vs baseline; Relay output equivalent
+
+
+

Why try this?

Scope.getTags() and getAttributes() copied thread-safe internal maps on every read. CombinedScopeView also built short-lived merged maps as ConcurrentHashMap, even though the merged view is transient.

+

What changed?

Return the internal Scope maps directly and use HashMap for CombinedScopeView merged tags, extras, and attributes.

+

Result

Kept. Allocation improved by ~46KB and CPU/wall were faster than baseline in this run. Later safety review found the raw internal map return was not acceptable API behavior, so that portion was reverted after the experiment batch.

+
+
+ Patch-style diff actual commit 06d34e023f +
diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java
+@@
+ import java.util.ArrayList;
++import java.util.HashMap;
+ import java.util.Collection;
+@@
+-import java.util.concurrent.ConcurrentHashMap;
+@@
+   public @NotNull Map<String, String> getTags() {
+-    final @NotNull Map<String, String> allTags = new ConcurrentHashMap<>();
++    final @NotNull Map<String, String> allTags = new HashMap<>();
+@@
+   public @NotNull Map<String, SentryAttribute> getAttributes() {
+-    final @NotNull Map<String, SentryAttribute> allAttributes = new ConcurrentHashMap<>();
++    final @NotNull Map<String, SentryAttribute> allAttributes = new HashMap<>();
+@@
+   public @NotNull Map<String, Object> getExtras() {
+-    final @NotNull Map<String, Object> allTags = new ConcurrentHashMap<>();
++    final @NotNull Map<String, Object> allTags = new HashMap<>();
+
+diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java
+@@
+   public @NotNull Map<String, String> getTags() {
+-    return CollectionUtils.newConcurrentHashMap(tags);
++    return tags;
+   }
+@@
+   public @NotNull Map<String, SentryAttribute> getAttributes() {
+-    return CollectionUtils.newConcurrentHashMap(attributes);
++    return attributes;
+   }
+
+

↑ Back to attempt list

+
+
+
+
+
AR-02 · discarded
+

Use ArrayDeque for CombinedScopeView breadcrumbs merge

+
+ discarded +
+
+
Allocation4,034,544B−54,192B / −1.33% vs baseline
+
CPU median58.561ms−1.445ms / −2.41% vs baseline; failed guardrail
+
Wall median26.926ms+3.931ms / +17.10% vs baseline; Relay output equivalent
+
+
+

Why try this?

The combined breadcrumb queue is transient. The idea was to avoid CircularFifoQueue + SynchronizedQueue overhead when only a short-lived merged queue is needed.

+

What changed?

Replace the Scope.createBreadcrumbsList helper call with an ArrayDeque in CombinedScopeView.getBreadcrumbs().

+

Result

Discarded. It saved a small amount of allocation, but CPU and wall time exceeded the 10% guardrail. It also risked subtly changing circular-capacity behavior.

+
+
+ Patch-style diff reconstructed patch-style diff (discarded worktree change was reset) +
diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java
+@@
+ import java.util.ArrayList;
++import java.util.ArrayDeque;
+@@
+     Collections.sort(allBreadcrumbs);
+ 
+-    final @NotNull Queue<Breadcrumb> breadcrumbs =
+-        createBreadcrumbsList(scope.getOptions().getMaxBreadcrumbs());
++    final @NotNull Queue<Breadcrumb> breadcrumbs =
++        new ArrayDeque<>(scope.getOptions().getMaxBreadcrumbs());
+     breadcrumbs.addAll(allBreadcrumbs);
+ 
+     return breadcrumbs;
+
+

↑ Back to attempt list

+
+
+
+
+
AR-03 · discarded
+

Remove Contexts deep-copy in applyScope only

+
+ discarded +
+
+
Allocation4,037,688B−51,048B / −1.25% vs baseline
+
CPU median58.364ms−1.642ms / −2.74% vs baseline; failed guardrail
+
Wall median23.218ms+0.223ms / +0.97% vs baseline; Relay output equivalent
+
+
+

Why try this?

SentryClient.applyScope created new Contexts(scope.getContexts()) just to iterate entries. That deep-cloned context objects before immediately copying selected entries again.

+

What changed?

Iterate scope.getContexts().entrySet() directly in three applyScope variants.

+

Result

Discarded as a standalone attempt. The change was correct, but too small and hit CPU/wall noise guardrails. It was later kept as part of AR-04 with the tags/extras double-copy removal.

+
+
+ Patch-style diff reconstructed from later kept patch +
diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java
+@@
+-    for (Map.Entry<String, Object> entry : new Contexts(scope.getContexts()).entrySet()) {
++    for (Map.Entry<String, Object> entry : scope.getContexts().entrySet()) {
+       if (!contexts.containsKey(entry.getKey())) {
+         contexts.put(entry.getKey(), entry.getValue());
+       }
+@@
+-      for (Map.Entry<String, Object> entry : new Contexts(scope.getContexts()).entrySet()) {
++      for (Map.Entry<String, Object> entry : scope.getContexts().entrySet()) {
+         if (!contexts.containsKey(entry.getKey())) {
+           contexts.put(entry.getKey(), entry.getValue());
+         }
+@@
+-      for (Map.Entry<String, Object> entry : new Contexts(scope.getContexts()).entrySet()) {
++      for (Map.Entry<String, Object> entry : scope.getContexts().entrySet()) {
+         if (!contexts.containsKey(entry.getKey())) {
+           contexts.put(entry.getKey(), entry.getValue());
+         }
+
+

↑ Back to attempt list

+
+
+
+
+
AR-04 · kept
+

Eliminate applyScope double-copy for tags, extras, and contexts

+
+ kept +
+
+
Allocation4,024,504B−64,232B / −1.57% vs baseline
+
CPU median48.180ms−11.826ms / −19.71% vs baseline; passed
+
Wall median21.522ms−1.473ms / −6.41% vs baseline; Relay output equivalent
+
+
+

Why try this?

applyScope wrapped scope maps in new HashMap before passing them into setters that already copy internally. Contexts were also deep-copied just to iterate.

+

What changed?

Pass scope maps directly to setters and iterate contexts directly. This keeps the setter-owned copy while removing the redundant outer copy.

+

Result

Kept. Allocation improved by ~17.6KB with CPU/wall inside guardrails.

+
+
+ Patch-style diff actual commit f053b24f50 +
diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java
+@@
+     if (event.getTags() == null) {
+-      event.setTags(new HashMap<>(scope.getTags()));
++      event.setTags(scope.getTags());
+@@
+-    for (Map.Entry<String, Object> entry : new Contexts(scope.getContexts()).entrySet()) {
++    for (Map.Entry<String, Object> entry : scope.getContexts().entrySet()) {
+@@
+       if (replayEvent.getTags() == null) {
+-        replayEvent.setTags(new HashMap<>(scope.getTags()));
++        replayEvent.setTags(scope.getTags());
+@@
+-      for (Map.Entry<String, Object> entry : new Contexts(scope.getContexts()).entrySet()) {
++      for (Map.Entry<String, Object> entry : scope.getContexts().entrySet()) {
+@@
+       if (sentryBaseEvent.getTags() == null) {
+-        sentryBaseEvent.setTags(new HashMap<>(scope.getTags()));
++        sentryBaseEvent.setTags(scope.getTags());
+@@
+       if (sentryBaseEvent.getExtras() == null) {
+-        sentryBaseEvent.setExtras(new HashMap<>(scope.getExtras()));
++        sentryBaseEvent.setExtras(scope.getExtras());
+@@
+-      for (Map.Entry<String, Object> entry : new Contexts(scope.getContexts()).entrySet()) {
++      for (Map.Entry<String, Object> entry : scope.getContexts().entrySet()) {
+
+

↑ Back to attempt list

+
+
+
+
+
AR-05 · discarded
+

Remove MainEventProcessor options-tags double-copy

+
+ discarded +
+
+
Allocation4,026,512B−62,224B / −1.52% vs baseline
+
CPU median62.548ms+2.542ms / +4.24% vs baseline; failed guardrail
+
Wall median23.473ms+0.478ms / +2.08% vs baseline; Relay output equivalent
+
+
+

Why try this?

MainEventProcessor.setTags used new HashMap(options.getTags()) before calling an event setter, matching the same double-copy pattern as AR-04.

+

What changed?

Pass options.getTags() directly to event.setTags().

+

Result

Discarded. The benchmark has no option-level tags, so the change produced no measurable allocation win and CPU noise exceeded the guardrail.

+
+
+ Patch-style diff reconstructed patch-style diff +
diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java
+@@
+   private void setTags(final @NotNull SentryBaseEvent event) {
+     if (event.getTags() == null) {
+-      event.setTags(new HashMap<>(options.getTags()));
++      event.setTags(options.getTags());
+     } else {
+       for (Map.Entry<String, String> item : options.getTags().entrySet()) {
+
+

↑ Back to attempt list

+
+
+
+
+
AR-06 · discarded
+

Lazy-init Breadcrumb.data, first attempt

+
+ discarded +
+
+
Allocation3,988,704B−100,032B / −2.45% vs baseline
+
CPU median68.422ms+8.416ms / +14.03% vs baseline; failed guardrail
+
Wall median26.424ms+3.429ms / +14.91% vs baseline; Relay output equivalent
+
+
+

Why try this?

Most benchmark breadcrumbs had no data. Each breadcrumb still eagerly allocated a ConcurrentHashMap for data.

+

What changed?

Initialize data as Collections.emptyMap(), create a HashMap only on first setData(), and use HashMap for copied/deserialized breadcrumb data.

+

Result

Discarded on the first run because CPU/wall exceeded guardrails despite a large allocation win. The same change was retried as AR-10 and kept, showing measurement noise was significant.

+
+
+ Patch-style diff reconstructed from AR-10 kept patch +
diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java
+@@
+-import java.util.concurrent.ConcurrentHashMap;
++import java.util.HashMap;
+@@
+-  private @NotNull Map<String, @NotNull Object> data = new ConcurrentHashMap<>();
++  private @NotNull Map<String, @NotNull Object> data = Collections.emptyMap();
+@@
+-    final Map<String, Object> dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data);
+-    if (dataClone != null) {
+-      this.data = dataClone;
++    if (!breadcrumb.data.isEmpty()) {
++      this.data = new HashMap<>(breadcrumb.data);
+@@
+-    @NotNull Map<String, Object> data = new ConcurrentHashMap<>();
++    @NotNull Map<String, Object> data = new HashMap<>();
+@@
++      if (data.isEmpty() && data == Collections.EMPTY_MAP) {
++        data = new HashMap<>();
++      }
+       data.put(key, value);
+@@
+-                CollectionUtils.newConcurrentHashMap(
++                CollectionUtils.newHashMap(
+
+

↑ Back to attempt list

+
+
+
+
+
AR-07 · discarded
+

Lazy-init SpanContext tags and data maps

+
+ discarded +
+
+
Allocation4,021,888B−66,848B / −1.63% vs baseline
+
CPU median48.781ms−11.225ms / −18.71% vs baseline; passed but too small / requested discard
+
Wall median20.529ms−2.466ms / −10.72% vs baseline; Relay output equivalent
+
+
+

Why try this?

SpanContext eagerly allocates ConcurrentHashMap instances for tags and data. The hypothesis was that many spans do not write both maps.

+

What changed?

Start tags/data as empty maps and allocate ConcurrentHashMap on first write.

+

Result

Discarded. It saved only ~2.6KB because TransactionContext.fromPropagationContext uses a constructor path that still eagerly creates data. Complexity was not worth the small win.

+
+
+ Patch-style diff reconstructed patch-style diff +
diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java
+@@
+-  protected @NotNull Map<String, @NotNull String> tags = new ConcurrentHashMap<>();
++  protected @NotNull Map<String, @NotNull String> tags = Collections.emptyMap();
+@@
+-  protected @NotNull Map<String, Object> data = new ConcurrentHashMap<>();
++  protected @NotNull Map<String, Object> data = Collections.emptyMap();
+@@
+   public void setTag(final @Nullable String name, final @Nullable String value) {
+@@
+-      this.tags.put(name, value);
++      if (tags.isEmpty() && tags == Collections.EMPTY_MAP) {
++        tags = new ConcurrentHashMap<>();
++      }
++      this.tags.put(name, value);
+@@
+-      data.put(key, value);
++      if (data.isEmpty() && data == Collections.EMPTY_MAP) {
++        data = new ConcurrentHashMap<>();
++      }
++      data.put(key, value);
+
+

↑ Back to attempt list

+
+
+
+
+
AR-08 · kept
+

Short-circuit CombinedScopeView.getBreadcrumbs()

+
+ kept +
+
+
Allocation4,003,984B−84,752B / −2.07% vs baseline
+
CPU median48.176ms−11.830ms / −19.71% vs baseline; passed
+
Wall median19.565ms−3.430ms / −14.92% vs baseline; Relay output equivalent
+
+
+

Why try this?

The common workload had breadcrumbs in only one scope. The old code always allocated a list, copied all queues, sorted, created a circular queue, wrapped it, and copied again.

+

What changed?

If exactly one scope has breadcrumbs, return that queue directly. Only do the full merge path when multiple scopes contain breadcrumbs.

+

Result

Kept. Allocation improved by ~84.8KB and wall time improved by ~15% vs baseline.

+
+
+ Patch-style diff actual commit b78d163120 +
diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java
+@@
+   public @NotNull Queue<Breadcrumb> getBreadcrumbs() {
++    final @NotNull Queue<Breadcrumb> globalBc = globalScope.getBreadcrumbs();
++    final @NotNull Queue<Breadcrumb> isolationBc = isolationScope.getBreadcrumbs();
++    final @NotNull Queue<Breadcrumb> currentBc = scope.getBreadcrumbs();
++
++    // Fast path: only one scope has breadcrumbs (common case)
++    final boolean hasGlobal = !globalBc.isEmpty();
++    final boolean hasIsolation = !isolationBc.isEmpty();
++    final boolean hasCurrent = !currentBc.isEmpty();
++
++    if (!hasGlobal && !hasCurrent) {
++      return isolationBc;
++    }
++    if (!hasIsolation && !hasCurrent) {
++      return globalBc;
++    }
++    if (!hasGlobal && !hasIsolation) {
++      return currentBc;
++    }
++
++    // Multiple scopes have breadcrumbs — full merge
+     final @NotNull List<Breadcrumb> allBreadcrumbs = new ArrayList<>();
+-    allBreadcrumbs.addAll(globalScope.getBreadcrumbs());
+-    allBreadcrumbs.addAll(isolationScope.getBreadcrumbs());
+-    allBreadcrumbs.addAll(scope.getBreadcrumbs());
++    allBreadcrumbs.addAll(globalBc);
++    allBreadcrumbs.addAll(isolationBc);
++    allBreadcrumbs.addAll(currentBc);
+
+

↑ Back to attempt list

+
+
+
+
+
AR-09 · discarded
+

Use ArrayList instead of CopyOnWriteArrayList in CombinedScopeView

+
+ discarded +
+
+
Allocation4,006,192B−82,544B / −2.02% vs baseline
+
CPU median53.368ms−6.638ms / −11.06% vs baseline; failed guardrail
+
Wall median19.982ms−3.013ms / −13.10% vs baseline; Relay output equivalent
+
+
+

Why try this?

CombinedScopeView lists are read-only transient merged views. CopyOnWriteArrayList avoids concurrent modification but is expensive when writes occur.

+

What changed?

Use ArrayList for attachments and event processor merged lists.

+

Result

Discarded. Attachments were empty and event processor lists were small, so CopyOnWriteArrayList overhead did not matter in this workload.

+
+
+ Patch-style diff reconstructed patch-style diff +
diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java
+@@
+-import java.util.concurrent.CopyOnWriteArrayList;
+@@
+-    final @NotNull List<Attachment> allAttachments = new CopyOnWriteArrayList<>();
++    final @NotNull List<Attachment> allAttachments = new ArrayList<>();
+@@
+-    final @NotNull List<EventProcessor> allEventProcessors = new CopyOnWriteArrayList<>();
++    final @NotNull List<EventProcessor> allEventProcessors = new ArrayList<>();
+@@
+-    final @NotNull List<EventProcessorAndOrder> allEventProcessors = new CopyOnWriteArrayList<>();
++    final @NotNull List<EventProcessorAndOrder> allEventProcessors = new ArrayList<>();
+
+

↑ Back to attempt list

+
+
+
+
+
AR-10 · kept
+

Lazy-init Breadcrumb.data, retry

+
+ kept +
+
+
Allocation3,967,752B−120,984B / −2.96% vs baseline
+
CPU median47.970ms−12.036ms / −20.06% vs baseline; passed
+
Wall median18.248ms−4.747ms / −20.64% vs baseline; Relay output equivalent
+
+
+

Why try this?

AR-06 showed a strong allocation win but failed CPU/wall due to noisy timing. Retrying tested whether the allocation win was real.

+

What changed?

Same lazy Breadcrumb.data idea: default to Collections.emptyMap(), allocate HashMap only on first data write, and avoid ConcurrentHashMap for copy/deserialization paths.

+

Result

Kept. This became the recorded best: 3,967,752B allocated, −120,984B / −2.96% vs baseline. Relay output remained equivalent.

+
+
+ Patch-style diff actual commit 065ea67cfb +
diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java
+@@
+ import java.io.IOException;
+ import java.util.ArrayList;
++import java.util.HashMap;
+ import java.util.Collections;
+@@
+-  private @NotNull Map<String, @NotNull Object> data = new ConcurrentHashMap<>();
++  private @NotNull Map<String, @NotNull Object> data = Collections.emptyMap();
+@@
+-    final Map<String, Object> dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data);
+-    if (dataClone != null) {
+-      this.data = dataClone;
++    if (!breadcrumb.data.isEmpty()) {
++      this.data = new HashMap<>(breadcrumb.data);
+@@
+-    @NotNull Map<String, Object> data = new ConcurrentHashMap<>();
++    @NotNull Map<String, Object> data = new HashMap<>();
+@@
+     } else {
++      if (data.isEmpty() && data == Collections.EMPTY_MAP) {
++        data = new HashMap<>();
++      }
+       data.put(key, value);
+@@
+-    if (key == null) {
++    if (key == null || data.isEmpty()) {
+       return;
+@@
+-                CollectionUtils.newConcurrentHashMap(
++                CollectionUtils.newHashMap(
+
+

↑ Back to attempt list

+
+
+
+
+
AR-11 · discarded
+

Return unmodifiable Scope maps instead of raw internals

+
+ discarded +
+
+
Allocation3,973,696B−115,040B / −2.81% vs baseline
+
CPU median84.465ms+24.459ms / +40.76% vs baseline; failed guardrail
+
Wall median47.107ms+24.112ms / +104.86% vs baseline; Relay output equivalent
+
+
+

Why try this?

AR-01 returned raw internal maps, which saved copies but exposed mutable Scope internals. This attempted to keep the allocation win without allowing external mutation.

+

What changed?

Return Collections.unmodifiableMap(tags/attributes) from Scope getters.

+

Result

Discarded by measurement and then fully reverted for API safety. The run showed severe CPU/wall regression noise. Final tracked state restored defensive copies for Scope.getTags() and getAttributes().

+
+
+ Patch-style diff actual commit 9af1d54600; later reverted by e65556f610 +
diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java
+@@
+   public @NotNull Map<String, String> getTags() {
+-    return tags;
++    return Collections.unmodifiableMap(tags);
+   }
+@@
+   public @NotNull Map<String, SentryAttribute> getAttributes() {
+-    return attributes;
++    return Collections.unmodifiableMap(attributes);
+   }
+
+

↑ Back to attempt list

+
+
+
+
+
AR-12 · not benchmarked
+

Safety revert: restore defensive Scope copies

+
+ final +
+
+
Allocationnot measurednot measured
+
CPU mediannot measurednot measured
+
Wall mediannot measuredRelay output equivalent unless noted
+
+
+

Why try this?

The public Scope getters should not expose mutable internals. After AR-11 was discarded, the safest final code restored the original defensive-copy behavior.

+

What changed?

Reverted Scope.getTags() and getAttributes() to CollectionUtils.newConcurrentHashMap(...).

+

Result

Final worktree no longer contains AR-01’s risky raw-map getter behavior. The remaining final diff contains Breadcrumb.data lazy-init, CombinedScopeView HashMap/ breadcrumb fast path, and SentryClient applyScope copy reductions.

+
+
+ Patch-style diff actual commit e65556f610; corrective change after measured attempts +
diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java
+@@
+   public @NotNull Map<String, String> getTags() {
+-    return Collections.unmodifiableMap(tags);
++    return CollectionUtils.newConcurrentHashMap(tags);
+   }
+@@
+   public @NotNull Map<String, SentryAttribute> getAttributes() {
+-    return Collections.unmodifiableMap(attributes);
++    return CollectionUtils.newConcurrentHashMap(attributes);
+   }
+
+

↑ Back to attempt list

+
+
+ + diff --git a/org/datadog/jmxfetch/default-jmx-metrics.yaml b/org/datadog/jmxfetch/default-jmx-metrics.yaml new file mode 100644 index 00000000000..ffcaa7a7e0e --- /dev/null +++ b/org/datadog/jmxfetch/default-jmx-metrics.yaml @@ -0,0 +1,223 @@ +# Memory +- include: + domain: java.lang + type: Memory + attribute: + HeapMemoryUsage.used: + alias: jvm.heap_memory + metric_type: gauge + HeapMemoryUsage.committed: + alias: jvm.heap_memory_committed + metric_type: gauge + HeapMemoryUsage.init: + alias: jvm.heap_memory_init + metric_type: gauge + HeapMemoryUsage.max: + alias: jvm.heap_memory_max + metric_type: gauge + NonHeapMemoryUsage.used: + alias: jvm.non_heap_memory + metric_type: gauge + NonHeapMemoryUsage.committed: + alias: jvm.non_heap_memory_committed + metric_type: gauge + NonHeapMemoryUsage.init: + alias: jvm.non_heap_memory_init + metric_type: gauge + NonHeapMemoryUsage.max: + alias: jvm.non_heap_memory_max + metric_type: gauge + +# Direct Memory Buffers +- include: + domain: java.nio + type: BufferPool + name: direct + attribute: + Count: + alias: jvm.buffer_pool.direct.count + metric_type: gauge + MemoryUsed: + alias: jvm.buffer_pool.direct.used + metric_type: gauge + TotalCapacity: + alias: jvm.buffer_pool.direct.capacity + metric_type: gauge +- include: + domain: java.nio + type: BufferPool + name: mapped + attribute: + Count: + alias: jvm.buffer_pool.mapped.count + metric_type: gauge + MemoryUsed: + alias: jvm.buffer_pool.mapped.used + metric_type: gauge + TotalCapacity: + alias: jvm.buffer_pool.mapped.capacity + metric_type: gauge + +# Threads +- include: + domain: java.lang + type: Threading + attribute: + ThreadCount: + alias: jvm.thread_count + metric_type: gauge + +# CPU load +- include: + domain: java.lang + type: OperatingSystem + attribute: + ProcessCpuLoad: + alias: jvm.cpu_load.process + metric_type: gauge + SystemCpuLoad: + alias: jvm.cpu_load.system + metric_type: gauge + +# Classloading +- include: + domain: java.lang + type: ClassLoading + attribute: + LoadedClassCount: + alias: jvm.loaded_classes + metric_type: gauge + UnloadedClassCount: + alias: jvm.unloaded_classes + metric_type: gauge + +# Open File Descriptors +- include: + domain: java.lang + type: OperatingSystem + attribute: + OpenFileDescriptorCount: + alias: jvm.os.open_file_descriptors + metric_type: gauge + +# GC Memory Pools +- include: + domain: java.lang + type: MemoryPool + name: Eden Space + attribute: + Usage.used: + alias: jvm.gc.eden_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: PS Eden Space + attribute: + Usage.used: + alias: jvm.gc.eden_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: Par Eden Space + attribute: + Usage.used: + alias: jvm.gc.eden_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: G1 Eden Space + attribute: + Usage.used: + alias: jvm.gc.eden_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: ZGC Young Generation + attribute: + Usage.used: + alias: jvm.gc.eden_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: Survivor Space + attribute: + Usage.used: + alias: jvm.gc.survivor_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: PS Survivor Space + attribute: + Usage.used: + alias: jvm.gc.survivor_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: Par Survivor Space + attribute: + Usage.used: + alias: jvm.gc.survivor_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: G1 Survivor Space + attribute: + Usage.used: + alias: jvm.gc.survivor_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: PS Old Gen + attribute: + Usage.used: + alias: jvm.gc.old_gen_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: CMS Old Gen + attribute: + Usage.used: + alias: jvm.gc.old_gen_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: G1 Old Gen + attribute: + Usage.used: + alias: jvm.gc.old_gen_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: ZGC Old Generation + attribute: + Usage.used: + alias: jvm.gc.old_gen_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: Tenured Gen + attribute: + Usage.used: + alias: jvm.gc.old_gen_size + metric_type: gauge +- include: + domain: java.lang + type: MemoryPool + name: Metaspace + attribute: + Usage.used: + alias: jvm.gc.metaspace_size + metric_type: gauge \ No newline at end of file diff --git a/org/datadog/jmxfetch/new-gc-default-jmx-metrics.yaml b/org/datadog/jmxfetch/new-gc-default-jmx-metrics.yaml new file mode 100644 index 00000000000..13105b31689 --- /dev/null +++ b/org/datadog/jmxfetch/new-gc-default-jmx-metrics.yaml @@ -0,0 +1,267 @@ +--- +# Young Gen Collectors (Minor Collections) +- include: + domain: java.lang + type: GarbageCollector + name: Copy + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: PS Scavenge + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ParNew + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: G1 Young Generation + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter + +# Old Gen Collectors (Major collections) +- include: + domain: java.lang + type: GarbageCollector + name: MarkSweepCompact + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: PS MarkSweep + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ConcurrentMarkSweep + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: G1 Mixed Generation + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: G1 Old Generation + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: Shenandoah Cycles + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter + +# Z Garbage Collector +- include: + domain: java.lang + type: GarbageCollector + name: ZGC + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ZGC Cycles + attribute: + CollectionCount: + alias: jvm.gc.zgc_cycles_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.zgc_cycles_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ZGC Pauses + attribute: + CollectionCount: + alias: jvm.gc.zgc_pauses_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.zgc_pauses_collection_time + metric_type: counter + +# Z Garbage Collector with ZGenerational +- include: + domain: java.lang + type: GarbageCollector + name: ZGC Major Cycles + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ZGC Major Pauses + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ZGC Minor Cycles + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: ZGC Minor Pauses + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter + +# IBM J9 gencon +- include: + domain: java.lang + type: GarbageCollector + name: scavenge + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: global + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter + +# IBM J9 balanced +- include: + domain: java.lang + type: GarbageCollector + name: partial gc + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: global garbage collect + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter + +# GraalVM Native +- include: + domain: java.lang + type: GarbageCollector + name: young generation scavenger + attribute: + CollectionCount: + alias: jvm.gc.minor_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.minor_collection_time + metric_type: counter +- include: + domain: java.lang + type: GarbageCollector + name: complete scavenger + attribute: + CollectionCount: + alias: jvm.gc.major_collection_count + metric_type: counter + CollectionTime: + alias: jvm.gc.major_collection_time + metric_type: counter \ No newline at end of file diff --git a/org/datadog/jmxfetch/old-gc-default-jmx-metrics.yaml b/org/datadog/jmxfetch/old-gc-default-jmx-metrics.yaml new file mode 100644 index 00000000000..7ffa74e41fd --- /dev/null +++ b/org/datadog/jmxfetch/old-gc-default-jmx-metrics.yaml @@ -0,0 +1,12 @@ + +# Old GC metrics for compatibility +- include: + domain: java.lang + type: GarbageCollector + attribute: + CollectionCount: + alias: jvm.gc.cms.count + metric_type: gauge + CollectionTime: + alias: jvm.gc.parnew.time + metric_type: gauge \ No newline at end of file diff --git a/scripts/update-spring-boot-versions.py b/scripts/update-spring-boot-versions.py new file mode 100755 index 00000000000..e7414218033 --- /dev/null +++ b/scripts/update-spring-boot-versions.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import requests +from packaging import version + + +def get_spring_boot_versions(): + """Fetch all Spring Boot versions from Maven Central with retry logic.""" + max_retries = 3 + timeout = 60 + + for attempt in range(max_retries): + try: + print(f"Fetching versions (attempt {attempt + 1}/{max_retries})...") + + metadata_url = "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot/maven-metadata.xml" + response = requests.get(metadata_url, timeout=timeout) + + if response.status_code == 200: + print("Using Maven metadata XML approach...") + root = ET.fromstring(response.text) + versions = [] + versioning = root.find("versioning") + if versioning is not None: + versions_element = versioning.find("versions") + if versions_element is not None: + for version_elem in versions_element.findall("version"): + candidate = version_elem.text + if is_supported_version(candidate): + versions.append(candidate) + + if versions: + print(f"Found {len(versions)} versions via XML") + print(f"Sample versions: {versions[-10:] if len(versions) > 10 else versions}") + valid_versions = parseable_versions(versions) + print(f"Filtered to {len(valid_versions)} valid versions") + return sorted(valid_versions, key=version.parse) + + print("Trying search API fallback...") + search_url = "https://search.maven.org/solrsearch/select" + params = { + "q": 'g:"org.springframework.boot" AND a:"spring-boot"', + "core": "gav", + "rows": 1000, + "wt": "json", + } + + response = requests.get(search_url, params=params, timeout=timeout) + response.raise_for_status() + data = response.json() + + if "response" not in data or "docs" not in data["response"]: + raise Exception("Unexpected API response structure") + + docs = data["response"]["docs"] + print(f"Found {len(docs)} documents in search response") + + if docs: + print(f"Sample doc structure: {list(docs[0].keys())}") + + versions = [] + for doc in docs: + candidate = doc.get("v") or doc.get("version") + if is_supported_version(candidate): + versions.append(candidate) + + if versions: + valid_versions = parseable_versions(versions) + print(f"Successfully fetched {len(valid_versions)} valid versions via search API") + return sorted(valid_versions, key=version.parse) + + except Exception as e: + print(f"Attempt {attempt + 1} failed: {e}") + if attempt < max_retries - 1: + print("Retrying...") + continue + + print("All attempts failed") + return [] + + +def is_supported_version(candidate): + return ( + candidate + and not any(suffix in candidate for suffix in ["SNAPSHOT", "RC", "BUILD", "RELEASE"]) + and candidate[0].isdigit() + and candidate.count(".") >= 2 + ) + + +def parseable_versions(versions): + valid_versions = [] + for candidate in versions: + try: + version.parse(candidate) + valid_versions.append(candidate) + except Exception: + print(f"Skipping invalid version format: {candidate}") + return valid_versions + + +def parse_current_versions(json_file): + """Parse current Spring Boot versions from JSON data file.""" + if not Path(json_file).exists(): + return [] + + try: + with open(json_file) as f: + data = json.load(f) + return data.get("versions", []) + except Exception as e: + print(f"Error reading {json_file}: {e}") + return [] + + +def get_latest_patch(all_versions, minor_version): + """Get the latest patch version for a given minor version.""" + target_minor = ".".join(minor_version.split(".")[:2]) + patches = [v for v in all_versions if v.startswith(target_minor + ".")] + return max(patches, key=version.parse) if patches else minor_version + + +def update_version_matrix(current_versions, all_versions, major_version): + """Update version matrix based on available versions.""" + if not current_versions or not all_versions: + return current_versions, False + + major_versions = [v for v in all_versions if v.startswith(f"{major_version}.")] + if not major_versions: + return current_versions, False + + updated_versions = [] + changes_made = False + + min_version = current_versions[0] + updated_versions.append(min_version) + + for curr_version in current_versions[1:]: + if any(suffix in curr_version for suffix in ["M", "RC", "SNAPSHOT"]): + updated_versions.append(curr_version) + continue + + latest_patch = get_latest_patch(major_versions, curr_version) + if latest_patch != curr_version: + print(f"Updating {curr_version} -> {latest_patch}") + changes_made = True + updated_versions.append(latest_patch) + + current_minors = set() + for candidate in current_versions: + if not any(suffix in candidate for suffix in ["M", "RC", "SNAPSHOT"]): + current_minors.add(".".join(candidate.split(".")[:2])) + + available_minors = set() + for candidate in major_versions: + if not any(suffix in candidate for suffix in ["M", "RC", "SNAPSHOT"]): + available_minors.add(".".join(candidate.split(".")[:2])) + + new_minors = available_minors - current_minors + if new_minors: + for new_minor in sorted(new_minors, key=version.parse): + latest_patch = get_latest_patch(major_versions, new_minor + ".0") + updated_versions.append(latest_patch) + print(f"Adding new minor version: {latest_patch}") + changes_made = True + + if len(updated_versions) > 7: + sorted_versions = sorted(updated_versions, key=version.parse) + min_version = sorted_versions[0] + other_versions = sorted_versions[1:] + + if len(other_versions) > 6: + updated_versions = [min_version] + other_versions[1:] + print(f"Removed second oldest version: {other_versions[0]}") + changes_made = True + + min_version = updated_versions[0] + other_versions = sorted( + [candidate for candidate in updated_versions if candidate != min_version], + key=version.parse, + ) + final_versions = [min_version] + other_versions + + seen = set() + deduplicated_versions = [] + for candidate in final_versions: + if candidate not in seen: + seen.add(candidate) + deduplicated_versions.append(candidate) + + if len(deduplicated_versions) != len(final_versions): + print(f"Removed {len(final_versions) - len(deduplicated_versions)} duplicate versions") + + return deduplicated_versions, changes_made + + +def update_json_file(json_file, new_versions): + """Update the JSON data file with new versions.""" + try: + data = {"versions": new_versions} + with open(json_file, "w") as f: + json.dump(data, f, indent=2, separators=(",", ": ")) + f.write("\n") + return True + except Exception as e: + print(f"Error writing to {json_file}: {e}") + return False + + +def write_github_output(change_summary): + output_file = os.environ.get("GITHUB_OUTPUT") + if not output_file: + return + + with open(output_file, "a") as f: + f.write("changes_summary< {new_versions}" + ) + else: + print("No changes needed") + + if changes_made: + print("\nChanges made to Spring Boot version files:") + for change in change_summary: + print(f" - {change}") + write_github_output(change_summary) + else: + print("\nNo version updates needed") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spring-boot-matrix-changes-presentation.html b/spring-boot-matrix-changes-presentation.html new file mode 100644 index 00000000000..276297d3f66 --- /dev/null +++ b/spring-boot-matrix-changes-presentation.html @@ -0,0 +1,416 @@ + + + + + + Spring Boot Matrix Fixes — Code Changes and Outcome + + + +
+ + +
+
+
PR #5372 · ci/fix-spring-boot-matrix-version-sed
+

Spring Boot matrix fixes

+

A scrollable summary of the code changes performed and the outcome: matrix jobs now exercise the intended Spring Boot versions, with compatibility fixes for supported sample combinations.

+
+ Base: main + Branch: ci/fix-spring-boot-matrix-version-sed + 22 tracked files changed + 249 insertions · 81 deletions +
+
+
+ +
+
+
+
Why this changed
+

The matrix override did not match the TOML format.

+

The workflows attempted to replace keys like springboot2=..., but the version catalog uses quoted TOML assignments such as springboot2 = "2.7.18". That meant CI could say it was testing a matrix version while Gradle still used the default catalog value.

+
+
BeforeVersion replacement was a no-op

The old sed pattern did not tolerate whitespace or quoted values.

+
FixReplace the quoted TOML value

The new expressions preserve the left-hand assignment and update only the version string.

+
AfterMatrix versions are real

Spring Boot 2.x, 3.x, and 4.x jobs now run with their requested versions.

+
+
+
+
+ +
+
+
Change scope
+

What areas were touched

+
+

CI workflows

  • Fixed Spring Boot 2/3/4 matrix version substitution.
  • Trimmed matrix versions to supported combinations.
  • Set Gradle project flags to exclude GraphQL/Kafka for older Boot 2 jobs.
+

Sample builds

  • Added optional GraphQL/Kafka dependencies and source exclusions.
  • Imported the tested Spring Boot BOM in Jakarta samples.
  • Aligned Java/Kotlin targets to Java 11 for Boot 2 samples.
+

Spring compatibility

  • Changed GraphQL auto-configuration class checks to string-based names.
  • Used Spring GraphQL directly as compile-only dependency.
  • Adjusted batch loader options signature for Spring GraphQL 1.2–1.4 compatibility.
+

System tests

  • Skipped Kafka broker/profile setup when Kafka is excluded.
  • Excluded GraphQL/Kafka system test classes for unsupported Boot 2 matrix versions.
  • Kept backend system test coverage passing for supported modules.
+
+
+
+ +
+
+
+
Key changes
+

Implementation highlights

+
+
Area
Change performed
Outcome
+
Matrix sed
Updated Spring Boot 2/3/4 workflows to replace springbootN = "..." values, preserving whitespace.
Fixed
+
Boot 2 coverage
Removed unsupported 2.1/2.2 jobs; kept 2.4.13, 2.5.15, 2.6.15, 2.7.0, 2.7.18.
Passing
+
Optional integrations
GraphQL and Kafka dependencies, sources, resources, and tests are conditionally excluded for older Boot 2 versions.
Stable
+
Spring GraphQL
Compile against Spring GraphQL directly and erase Consumer in batch loader wrapper for API compatibility.
Compatible
+
WebFlux sample
Removed deprecated scheduler usage and replaced newer basic auth helper with Java Base64.
Compiles
+
+
+
+
+ +
+
+
Representative code
+

Small targeted changes did the work

+
+
+

Workflow TOML replacement

+
springboot_version="${{ matrix.springboot-version }}"
+if [[ ! "$springboot_version" =~ ^2\.7\. ]]; then
+  echo "ORG_GRADLE_PROJECT_excludeGraphql=true" >> "$GITHUB_ENV"
+  echo "ORG_GRADLE_PROJECT_excludeKafka=true" >> "$GITHUB_ENV"
+fi
+sed -i 's/^\(springboot2[[:space:]]*=[[:space:]]*\)".*"/\1"'"$springboot_version"'"/' gradle/libs.versions.toml
+
+
+

Optional sample integrations

+
val includeGraphql =
+  !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations()
+val includeKafka =
+  !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations()
+
+if (includeGraphql) {
+  implementation(libs.springboot.starter.graphql)
+  implementation(projects.sentryGraphql)
+}
+
+
+

String-based auto-configuration checks

+
@ConditionalOnClass(
+  name = {
+    "io.sentry.graphql.SentryGraphqlExceptionHandler",
+    "org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter",
+    "graphql.GraphQLError"
+  })
+static class GraphqlConfiguration {}
+
+
+

Spring GraphQL API compatibility

+
@SuppressWarnings({"rawtypes", "unchecked"})
+public BatchLoaderRegistry.RegistrationSpec<K, V> withOptions(
+    Consumer optionsConsumer) {
+  return delegate.withOptions(optionsConsumer);
+}
+
+
+
+
+ +
+
+
+
Outcome
+

The intended compatibility matrix is now green.

+

The Spring Boot matrix checks completed successfully across supported Boot 2, Boot 3, and Boot 4 versions. Backend system tests for Spring samples also passed with the adjusted dependency/test exclusions.

+
+
5/5Spring Boot 2 matrix jobs passed

2.4.13, 2.5.15, 2.6.15, 2.7.0, and 2.7.18.

+
4/4Spring Boot 3 matrix jobs passed

3.2.12, 3.3.13, 3.4.13, and 3.5.13.

+
2/2Spring Boot 4 matrix jobs passed

4.0.0 and 4.0.5.

+
61successful checks reported by GitHub

Build, format, benchmarks, matrix jobs, backend system tests, Warden, license, secret scan, and size/snapshot checks.

+
1remaining blocking check

The PR is still marked BLOCKED because the aggregate Ui tests check reports failure, even though the listed critical UI test jobs for API 31/33/35/36 show success.

+
+
+
+
+ +
+
+
Verification snapshot
+

CI status at compilation time

+
+
61Success

Completed checks with successful conclusions.

+
1Skipped

validate-high-risk-code was skipped.

+
1Failure

Aggregate Ui tests check reports failure.

+
+

Notable passing checks

+
    +
  • Build Job ubuntu-latest - Java 17
  • +
  • Format Code
  • +
  • Spring Boot 2.x Matrix: 2.4.13, 2.5.15, 2.6.15, 2.7.0, 2.7.18
  • +
  • Spring Boot 3.x Matrix: 3.2.12, 3.3.13, 3.4.13, 3.5.13
  • +
  • Spring Boot 4.x Matrix: 4.0.0, 4.0.5
  • +
  • System Tests Backend: Spring Boot, WebFlux, Jakarta, OTel, Boot 4, Spring 7, Spring Jakarta, and Spring samples
  • +
+
+
+
+
+ +
+
+
+
Commit timeline
+

How the fix evolved

+
+
104267513d
Fix Spring Boot matrix version updates

Corrected version catalog replacement so matrix jobs actually test their requested versions.

+
917ebada4d
Limit Spring Boot matrix to supported versions

Removed versions the current sample setup cannot build with.

+
52df6e99f3
Restore Spring Boot matrix coverage

Expanded supported Boot 2/3 matrix coverage and excluded GraphQL where unavailable.

+
b8a9c0bde8
Avoid deprecated Reactor scheduler in sample

Removed scheduler usage that triggered warnings under -Werror.

+
87b8bc3f8a
Exclude Kafka from old Boot 2 matrix

Excluded Kafka paths for old Boot 2 dependency management and aligned test BOMs.

+
37d04c6b59
Support older Spring GraphQL options API

Made the batch loader registry compile across Spring GraphQL 1.2/1.3 and 1.4.

+
+
+
+
+ +
+
+
Current state
+

Ready except for the remaining UI aggregate check.

+
+

Summary

  • The matrix substitution bug is fixed.
  • Supported Spring Boot compatibility coverage is restored and passing.
  • Sample builds/tests now adapt to integrations unavailable in older Boot 2 versions.
  • PR #5372 remains open and blocked by one reported UI aggregate failure.
+

Notes

This presentation summarizes tracked branch changes compared with main. The working tree also contains an untracked org/datadog/jmxfetch/ directory, which is not part of this PR summary.

+
+
+
+ +