diff --git a/CHANGELOG.next-release.md b/CHANGELOG.next-release.md
index 72694259c1f..9357a4f761d 100644
--- a/CHANGELOG.next-release.md
+++ b/CHANGELOG.next-release.md
@@ -11,6 +11,7 @@ This file contains all changes which are not released yet.
- avoid caching when reading version from jar to prevent side effects - [#4543](https://github.com/elastic/apm-agent-java/pull/4543)
+- fix Spring Webflux 7 NoSuchMethodError on HttpHeaders#entrySet() - [#4556](https://github.com/elastic/apm-agent-java/pull/4556)
# Features and enhancements
diff --git a/apm-agent-core/src/test/java/co/elastic/apm/agent/testutils/Java17OnlyTest.java b/apm-agent-core/src/test/java/co/elastic/apm/agent/testutils/Java17OnlyTest.java
index f681030f001..a58515ca11c 100644
--- a/apm-agent-core/src/test/java/co/elastic/apm/agent/testutils/Java17OnlyTest.java
+++ b/apm-agent-core/src/test/java/co/elastic/apm/agent/testutils/Java17OnlyTest.java
@@ -35,21 +35,21 @@
* Why not just put @EnabledForJreRange directly on the test classes?
* JUnit reflectively loads the target class when discovering tests.
* Because of spring, the tests contain references/annotations compiled with Java 17.
- * This in turn leads to UnsupportedClassVersionErrors before JUnit can evaluate the @EnableForJRERange when running on older java versions (e.g. 11).
+ * This in turn leads to UnsupportedClassVersionErrors before JUnit can evaluate the @EnabledForJreRange when running on older java versions (e.g. 11).
*
* Therefore, this class can be used to wrap tests, as it programmatically triggers the test execution.
* The actual test implementation should not be named *Test to not be discovered by the maven surefire plugin.
*/
public abstract class Java17OnlyTest {
- private Class> actualTestClass;
+ private final Class> actualTestClass;
public Java17OnlyTest(Class> testClazz) {
this.actualTestClass = testClazz;
}
- @EnabledForJreRange(min = JRE.JAVA_17)
@Test
+ @EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_25)
public void runTests() {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(selectClass(actualTestClass))
diff --git a/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/SubscriptionCancelInstrumentation.java b/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/SubscriptionCancelInstrumentation.java
new file mode 100644
index 00000000000..16e18e1b7f2
--- /dev/null
+++ b/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/SubscriptionCancelInstrumentation.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.reactor;
+
+import co.elastic.apm.agent.sdk.ElasticApmInstrumentation;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.NamedElement;
+import net.bytebuddy.description.method.MethodDescription;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+import org.reactivestreams.Subscription;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import static co.elastic.apm.agent.sdk.bytebuddy.CustomElementMatchers.classLoaderCanLoadClass;
+import static net.bytebuddy.matcher.ElementMatchers.declaresMethod;
+import static net.bytebuddy.matcher.ElementMatchers.hasSuperType;
+import static net.bytebuddy.matcher.ElementMatchers.isInterface;
+import static net.bytebuddy.matcher.ElementMatchers.nameContains;
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.not;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+
+public class SubscriptionCancelInstrumentation extends ElasticApmInstrumentation {
+
+ @Override
+ public ElementMatcher.Junction getClassLoaderMatcher() {
+ return classLoaderCanLoadClass("reactor.core.CoreSubscriber");
+ }
+
+ @Override
+ public ElementMatcher super NamedElement> getTypeMatcherPreFilter() {
+ return nameContains("Subscriber").or(nameContains("Subscription"));
+ }
+
+ @Override
+ public ElementMatcher super TypeDescription> getTypeMatcher() {
+ return not(isInterface())
+ .and(declaresMethod(getMethodMatcher()))
+ .and(hasSuperType(named("org.reactivestreams.Subscription")));
+ }
+
+ @Override
+ public ElementMatcher super MethodDescription> getMethodMatcher() {
+ return named("cancel").and(takesArguments(0));
+ }
+
+ @Override
+ public Collection getInstrumentationGroupNames() {
+ return Collections.singleton("reactor");
+ }
+
+ @Override
+ public String getAdviceClassName() {
+ return "co.elastic.apm.agent.reactor.SubscriptionCancelInstrumentation$CancelAdvice";
+ }
+
+ public static class CancelAdvice {
+
+ @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class, inline = false)
+ public static void afterCancel(@Advice.This Subscription subscription) {
+ TracedSubscriber.onCancel(subscription);
+ }
+ }
+}
diff --git a/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/TracedSubscriber.java b/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/TracedSubscriber.java
index 5b381c78b2a..0dc0cbf5fb6 100644
--- a/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/TracedSubscriber.java
+++ b/apm-agent-plugins/apm-reactor-plugin/src/main/java/co/elastic/apm/agent/reactor/TracedSubscriber.java
@@ -23,6 +23,7 @@
import co.elastic.apm.agent.sdk.state.GlobalVariables;
import co.elastic.apm.agent.sdk.weakconcurrent.WeakConcurrent;
import co.elastic.apm.agent.sdk.weakconcurrent.WeakMap;
+import co.elastic.apm.agent.sdk.weakconcurrent.WeakSet;
import co.elastic.apm.agent.tracer.TraceState;
import co.elastic.apm.agent.tracer.GlobalTracer;
import co.elastic.apm.agent.tracer.Tracer;
@@ -49,6 +50,8 @@ public class TracedSubscriber implements CoreSubscriber {
private static final ReferenceCountedMap, TraceState>> contextMap = GlobalTracer.get().newReferenceCountedMap();
+ private static final WeakMap>> subscriptionMap = WeakConcurrent.buildMap();
+
private static final String HOOK_KEY = "elastic-apm";
private final CoreSubscriber super T> subscriber;
@@ -82,6 +85,7 @@ public void onSubscribe(Subscription s) {
boolean hasActivated = doEnter("onSubscribe", context);
Throwable thrown = null;
try {
+ registerSubscription(s);
subscriber.onSubscribe(s);
} catch (Throwable e) {
thrown = e;
@@ -187,6 +191,32 @@ private void doExit(boolean deactivate, String method, @Nullable TraceState> c
context.deactivate();
}
+ private void registerSubscription(Subscription subscription) {
+ WeakSet> subscribers = subscriptionMap.get(subscription);
+ if (subscribers == null) {
+ WeakSet> newSubscribers = WeakConcurrent.buildSet();
+ subscribers = subscriptionMap.putIfAbsent(subscription, newSubscribers);
+ if (subscribers == null) {
+ subscribers = newSubscribers;
+ }
+ }
+ subscribers.add(this);
+ }
+
+ /**
+ * Cancellation does not emit a terminal signal, so it is observed by
+ * {@link SubscriptionCancelInstrumentation} instead.
+ */
+ static void onCancel(Subscription subscription) {
+ WeakSet> subscribers = subscriptionMap.remove(subscription);
+ if (subscribers == null) {
+ return;
+ }
+ for (TracedSubscriber> subscriber : subscribers) {
+ subscriber.discardIf(true);
+ }
+ }
+
private void discardIf(boolean condition) {
if (!condition) {
return;
diff --git a/apm-agent-plugins/apm-reactor-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.sdk.ElasticApmInstrumentation b/apm-agent-plugins/apm-reactor-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.sdk.ElasticApmInstrumentation
index c1e3c2389f0..f8b315a96cc 100644
--- a/apm-agent-plugins/apm-reactor-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.sdk.ElasticApmInstrumentation
+++ b/apm-agent-plugins/apm-reactor-plugin/src/main/resources/META-INF/services/co.elastic.apm.agent.sdk.ElasticApmInstrumentation
@@ -1 +1,2 @@
co.elastic.apm.agent.reactor.ReactorInstrumentation
+co.elastic.apm.agent.reactor.SubscriptionCancelInstrumentation
diff --git a/apm-agent-plugins/apm-reactor-plugin/src/test/java/co/elastic/apm/agent/reactor/TracedSubscriberTest.java b/apm-agent-plugins/apm-reactor-plugin/src/test/java/co/elastic/apm/agent/reactor/TracedSubscriberTest.java
index 90ebb2290a1..9de8cf496bd 100644
--- a/apm-agent-plugins/apm-reactor-plugin/src/test/java/co/elastic/apm/agent/reactor/TracedSubscriberTest.java
+++ b/apm-agent-plugins/apm-reactor-plugin/src/test/java/co/elastic/apm/agent/reactor/TracedSubscriberTest.java
@@ -91,7 +91,10 @@ void after() {
// ensure clean as new hooks setup for all tests (some might have removed it)
TracedSubscriber.unregisterHooks();
- TracedSubscriber.registerHooks(tracer);
+ // Register through ReactorInstrumentation so the hook and the cancel advice use
+ // TracedSubscriber classes loaded by the same plugin class loader.
+ Mono.just(1);
+ checkHookRegistration(true, "hook should be registered automatically after each test");
flushGcExpiry(3);
}
@@ -223,6 +226,21 @@ void contextPropagation_Flux_error() {
.verifyErrorMatches(t -> t == error);
}
+ @Test
+ void cancelledSubscription_releasedContext() {
+ transaction = startTestRootTransaction("root");
+ int initialReferenceCount = transaction.getReferenceCount();
+
+ Flux flux = Flux.just(1, 2, 3)
+ .subscribeOn(SUBSCRIBE_SCHEDULER);
+
+ StepVerifier.create(flux.log())
+ .thenCancel()
+ .verify();
+
+ assertThat(transaction.getReferenceCount()).isEqualTo(initialReferenceCount);
+ }
+
@Test
void ignoreNoActiveContext() {
assertThat(tracer.getActive()).isNull();
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/pom.xml b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/pom.xml
new file mode 100644
index 00000000000..2113fe9a094
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/pom.xml
@@ -0,0 +1,112 @@
+
+
+ 4.0.0
+
+
+ co.elastic.apm
+ apm-spring-webflux
+ 1.56.1-SNAPSHOT
+
+
+ apm-spring-webflux-plugin-spring7
+ ${project.groupId}:${project.artifactId}
+
+
+
+ ${project.basedir}/../../..
+
+ true
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${version.spring-boot-4}
+ pom
+ import
+
+
+
+
+
+
+ ${project.groupId}
+ apm-spring-webflux-spring5
+ ${project.version}
+
+
+
+
+ ${project.groupId}
+ apm-spring-webflux-testapp-spring7
+ ${project.version}
+ test
+
+
+ org.springframework
+ spring-web
+ provided
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ co.elastic.apm
+ apm-spring-webflux-spring5
+ ${project.version}
+ test
+ test-jar
+
+
+
+
+ co.elastic.apm
+ apm-reactor-plugin
+ ${project.version}
+ test
+
+
+
+ co.elastic.apm
+ apm-reactor-plugin
+ ${project.version}
+ test
+ test-jar
+
+
+
+ org.apache.ivy
+ ivy
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+
+
+
+
+
+ testing-jdk-11
+
+
+ test_java_version
+ 11
+
+
+
+ true
+
+
+
+
+
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7HeaderGetterTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7HeaderGetterTest.java
new file mode 100644
index 00000000000..0f308f8c5ce
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7HeaderGetterTest.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+import co.elastic.apm.agent.testutils.Java17OnlyTest;
+
+public class Spring7HeaderGetterTest extends Java17OnlyTest {
+
+ public Spring7HeaderGetterTest() {
+ super(Impl.class);
+ }
+
+ public static class Impl extends HeaderGetterTest {
+
+ }
+}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServerAnnotatedInstrumentationTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServerAnnotatedInstrumentationTest.java
new file mode 100644
index 00000000000..2bab1323904
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServerAnnotatedInstrumentationTest.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+
+import co.elastic.apm.agent.testutils.Java17OnlyTest;
+
+public class Spring7ServerAnnotatedInstrumentationTest extends Java17OnlyTest {
+
+ public Spring7ServerAnnotatedInstrumentationTest() {
+ super(Impl.class);
+ }
+
+ public static class Impl extends ServerAnnotatedInstrumentationTest {
+ }
+}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServerFunctionalInstrumentationTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServerFunctionalInstrumentationTest.java
new file mode 100644
index 00000000000..ef5299f6074
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServerFunctionalInstrumentationTest.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+import co.elastic.apm.agent.testutils.Java17OnlyTest;
+
+public class Spring7ServerFunctionalInstrumentationTest extends Java17OnlyTest {
+
+ public Spring7ServerFunctionalInstrumentationTest() {
+ super(Impl.class);
+ }
+
+ public static class Impl extends ServerFunctionalInstrumentationTest {
+ }
+}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServletContainerTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServletContainerTest.java
new file mode 100644
index 00000000000..491cface05b
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7ServletContainerTest.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+import co.elastic.apm.agent.testutils.Java17OnlyTest;
+
+public class Spring7ServletContainerTest extends Java17OnlyTest {
+
+ public Spring7ServletContainerTest() {
+ super(Impl.class);
+ }
+
+ public static class Impl extends ServletContainerTest {
+
+ }
+}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7TransactionAwareSubscriberTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7TransactionAwareSubscriberTest.java
new file mode 100644
index 00000000000..404388a4c8c
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7TransactionAwareSubscriberTest.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+import co.elastic.apm.agent.testutils.Java17OnlyTest;
+
+public class Spring7TransactionAwareSubscriberTest extends Java17OnlyTest {
+
+ public Spring7TransactionAwareSubscriberTest() {
+ super(Impl.class);
+ }
+
+ public static class Impl extends TransactionAwareSubscriberTest {
+ }
+}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7WebSocketServerInstrumentationTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7WebSocketServerInstrumentationTest.java
new file mode 100644
index 00000000000..5d35c2c1eb2
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-plugin-spring7/src/test/java/co/elastic/apm/agent/springwebflux/Spring7WebSocketServerInstrumentationTest.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+
+import co.elastic.apm.agent.testutils.Java17OnlyTest;
+
+public class Spring7WebSocketServerInstrumentationTest extends Java17OnlyTest {
+
+ public Spring7WebSocketServerInstrumentationTest() {
+ super(Impl.class);
+ }
+
+ public static class Impl extends WebSocketServerInstrumentationTest {
+
+ }
+}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriber.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriber.java
index e2d0cfa7a75..1d7446af7b0 100644
--- a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriber.java
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriber.java
@@ -87,8 +87,11 @@ public void request(long n) {
@Override
public void cancel() {
- subscription.cancel();
- cancelTransaction();
+ try {
+ cancelTransaction();
+ } finally {
+ subscription.cancel();
+ }
}
/**
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/WebfluxHelper.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/WebfluxHelper.java
index 3cdcdc3fb14..df4fa605f82 100644
--- a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/WebfluxHelper.java
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/main/java/co/elastic/apm/agent/springwebflux/WebfluxHelper.java
@@ -19,22 +19,22 @@
package co.elastic.apm.agent.springwebflux;
import co.elastic.apm.agent.httpserver.HttpServerHelper;
+import co.elastic.apm.agent.sdk.internal.util.LoggerUtils;
+import co.elastic.apm.agent.sdk.internal.util.PrivilegedActionUtils;
import co.elastic.apm.agent.sdk.internal.util.VersionUtils;
-import co.elastic.apm.agent.tracer.GlobalTracer;
-import co.elastic.apm.agent.tracer.metadata.PotentiallyMultiValuedMap;
-import co.elastic.apm.agent.tracer.util.ResultUtil;
-import co.elastic.apm.agent.tracer.configuration.CoreConfiguration;
-import co.elastic.apm.agent.tracer.configuration.WebConfiguration;
-import co.elastic.apm.agent.tracer.Tracer;
-import co.elastic.apm.agent.tracer.Transaction;
import co.elastic.apm.agent.sdk.logging.Logger;
import co.elastic.apm.agent.sdk.logging.LoggerFactory;
import co.elastic.apm.agent.sdk.weakconcurrent.WeakConcurrent;
import co.elastic.apm.agent.sdk.weakconcurrent.WeakMap;
+import co.elastic.apm.agent.tracer.GlobalTracer;
+import co.elastic.apm.agent.tracer.Tracer;
+import co.elastic.apm.agent.tracer.Transaction;
+import co.elastic.apm.agent.tracer.configuration.CoreConfiguration;
+import co.elastic.apm.agent.tracer.configuration.WebConfiguration;
+import co.elastic.apm.agent.tracer.metadata.PotentiallyMultiValuedMap;
import co.elastic.apm.agent.tracer.metadata.Request;
import co.elastic.apm.agent.tracer.metadata.Response;
-import co.elastic.apm.agent.sdk.internal.util.LoggerUtils;
-import co.elastic.apm.agent.sdk.internal.util.PrivilegedActionUtils;
+import co.elastic.apm.agent.tracer.util.ResultUtil;
import co.elastic.apm.agent.tracer.util.TransactionNameUtils;
import co.elastic.apm.agent.webfluxcommon.SpringWebVersionUtils;
import org.reactivestreams.Publisher;
@@ -58,6 +58,7 @@
import java.net.InetSocketAddress;
import java.util.List;
import java.util.Map;
+import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import static co.elastic.apm.agent.tracer.AbstractSpan.PRIORITY_HIGH_LEVEL_FRAMEWORK;
@@ -69,6 +70,7 @@ public class WebfluxHelper {
private static final String FRAMEWORK_NAME = "Spring Webflux";
private static final Logger log = LoggerFactory.getLogger(WebfluxHelper.class);
+ private static final Logger oneTimeResponseHeadersErrorLogger = LoggerUtils.logOnce(log);
private static final Logger oneTimeResponseCodeErrorLogger = LoggerUtils.logOnce(log);
public static final String TRANSACTION_ATTRIBUTE = WebfluxHelper.class.getName() + ".transaction";
@@ -211,7 +213,7 @@ public static void setTransactionName(@Nullable Transaction> transaction, Serv
}
String method = "unknown";
HttpMethod methodObj = exchange.getRequest().getMethod();
- if(methodObj != null) {
+ if (methodObj != null) {
method = methodObj.name();
}
StringBuilder transactionName = transaction.getAndOverrideName(namePriority, false);
@@ -303,7 +305,11 @@ private static void fillResponse(Transaction> transaction, ServerWebExchange e
Response response = transaction.getContext().getResponse();
if (coreConfig.isCaptureHeaders()) {
- copyHeaders(serverResponse.getHeaders(), response.getHeaders());
+ try {
+ copyHeaders(serverResponse.getHeaders(), response.getHeaders());
+ } catch (RuntimeException e) {
+ oneTimeResponseHeadersErrorLogger.error("Failed to capture response headers", e);
+ }
}
response
@@ -313,11 +319,7 @@ private static void fillResponse(Transaction> transaction, ServerWebExchange e
}
private static void copyHeaders(HttpHeaders source, PotentiallyMultiValuedMap destination) {
- for (Map.Entry> header : source.entrySet()) {
- for (String value : header.getValue()) {
- destination.add(header.getKey(), value);
- }
- }
+ source.forEach(new HeaderCopyConsumer(destination));
}
private static void copyCookies(MultiValueMap source, PotentiallyMultiValuedMap destination) {
@@ -328,4 +330,19 @@ private static void copyCookies(MultiValueMap source, Potent
}
}
+ private static class HeaderCopyConsumer implements BiConsumer> {
+
+ private final PotentiallyMultiValuedMap destination;
+
+ private HeaderCopyConsumer(PotentiallyMultiValuedMap destination) {
+ this.destination = destination;
+ }
+
+ @Override
+ public void accept(String key, List values) {
+ for (String value : values) {
+ destination.add(key, value);
+ }
+ }
+ }
}
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/AbstractServerInstrumentationTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/AbstractServerInstrumentationTest.java
index 10a46d32e4d..f3393a10f12 100644
--- a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/AbstractServerInstrumentationTest.java
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/AbstractServerInstrumentationTest.java
@@ -39,11 +39,14 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.core.SpringVersion;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Hooks;
import reactor.test.StepVerifier;
+import java.lang.reflect.Field;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
@@ -140,8 +143,8 @@ private void hello(boolean expectHeaders) {
.describedAs("non-standard request headers should be captured")
.isEqualTo("12345");
- assertThat(headers.getFirst("Accept"))
- .isEqualTo("text/plain, application/json");
+ assertThat(MediaType.parseMediaTypes(headers.getAll("Accept")))
+ .containsExactly(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON);
assertThat(request.getCookies()
.getFirst("cookie"))
@@ -209,6 +212,15 @@ private static int getStatusCode(WebClientResponseException exception) {
} catch (Exception | Error e) {
// silently ignored
}
+ try {
+ // Due to the many breaking changes in the spring framework API in version 7, we have to access the status code through reflection.
+ // This will check if it can retrieve the int code via the HttpStatus.value() method, as getRawStatusCode() has been removed in 7
+ Field statusCode = exception.getClass().getSuperclass().getDeclaredField("statusCode");
+ statusCode.setAccessible(true);
+ return ((HttpStatus)statusCode.get(exception)).value();
+ } catch (Exception | Error e) {
+ // silently ignored
+ }
try {
return exception.getRawStatusCode();
} catch (Exception | Error e) {
diff --git a/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriberTest.java b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriberTest.java
new file mode 100644
index 00000000000..82507e4a056
--- /dev/null
+++ b/apm-agent-plugins/apm-spring-webflux/apm-spring-webflux-spring5/src/test/java/co/elastic/apm/agent/springwebflux/TransactionAwareSubscriberTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package co.elastic.apm.agent.springwebflux;
+
+import co.elastic.apm.agent.AbstractInstrumentationTest;
+import co.elastic.apm.agent.impl.transaction.TransactionImpl;
+import org.junit.jupiter.api.Test;
+import org.reactivestreams.Subscription;
+import org.springframework.http.HttpCookie;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.CoreSubscriber;
+import reactor.util.context.Context;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TransactionAwareSubscriberTest extends AbstractInstrumentationTest {
+
+ @Test
+ void shouldCaptureResponseBeforeCancellingUpstream() {
+ String headerName = "X-Header";
+ String headerValue = "test";
+
+ AtomicBoolean responseInvalidated = new AtomicBoolean();
+ HttpHeaders responseHeaders = new HttpHeaders() {
+ @Override
+ public void forEach(BiConsumer super String, ? super List> action) {
+ if (responseInvalidated.get()) {
+ throw new NullPointerException("response headers have been invalidated");
+ }
+ super.forEach(action);
+ }
+ };
+ responseHeaders.add(headerName, headerValue);
+
+ AtomicBoolean upstreamCancelled = new AtomicBoolean();
+ TransactionAwareSubscriber