Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.next-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This file contains all changes which are not released yet.
<!--FIXES-START-->

- 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)

<!--FIXES-END-->
# Features and enhancements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* <p>
* 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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClassLoader> getClassLoaderMatcher() {
return classLoaderCanLoadClass("reactor.core.CoreSubscriber");
}

@Override
public ElementMatcher<? super NamedElement> getTypeMatcherPreFilter() {
return nameContains("Subscriber").or(nameContains("Subscription"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are a bunch in reactor that don't use those in the names. Maybe add that as a special case?

Suggested change
return nameContains("Subscriber").or(nameContains("Subscription"));
return nameStartsWith("reactor.")
.or(nameContains("Subscriber"))
.or(nameContains("Subscription"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking an LLM to find known implementations of Subscription yields the following list, so reactor. is a good prefix, we might also add io.reactivex. and io.smallrye.mutiny. but I'm definitely not familiar with any of those reactor frameworks. Here it would not hurt to have a few false positives rather than lots of false positives creating lots of useless overhead, so this should be fine as a first step, and we can refine further if needed.

reactor.core.publisher.FluxInterval$IntervalRunnable
reactor.core.publisher.FluxSwitchMap$SwitchMapInner
reactor.core.publisher.SinkManyUnicastNoBackpressure
io.reactivex.internal.operators.flowable.FlowableAmb$AmbCoordinator
io.reactivex.internal.operators.flowable.FlowablePublishMulticast$OutputCanceller
io.smallrye.mutiny.operators.multi.MultiFlatMapOp$FlatMapInner
io.smallrye.mutiny.operators.multi.MultiCombineLatestOp$CombineLatestCoordinator
io.smallrye.mutiny.operators.multi.MultiGroupByOp$State
io.smallrye.mutiny.operators.multi.MultiZipOp$ZipCoordinator
io.smallrye.mutiny.operators.multi.processors.UnicastProcessor
io.smallrye.mutiny.operators.multi.MultiOperatorProcessor
io.smallrye.mutiny.operators.multi.builders.IntervalMulti$IntervalRunnable
org.springframework.http.server.reactive.ChannelSendOperator$WriteBarrier
org.springframework.http.server.reactive.ChannelSendOperator$WriteCompletionBarrier

@mtomik mtomik Sep 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one drawback of this additional name matching is increased instruction time spent. So I run the numbers again:

current filter

2026-09-04 00:03:18,209 [ForkJoinPool-2-worker-1] DEBUG co.elastic.apm.agent.bci.InstrumentationStatsLifecycleListener - Total time spent matching: 257,422,186ns
| Advice name                                        | Type ns         | Method ns       |
| ExecutorInstrumentation$ExecutorRunnableInstrumentation |      46,278,675 |       5,203,226 |
| SubscriptionCancelInstrumentation                  |      17,952,170 |      11,488,096 |
| JakartaFilterInstrumentation                       |      24,123,333 |               0 |

OR starts with "reactor."

2026-09-03 23:59:18,358 [ForkJoinPool-2-worker-1] DEBUG co.elastic.apm.agent.bci.InstrumentationStatsLifecycleListener - Total time spent matching: 282,551,157ns
| Advice name                                        | Type ns         | Method ns       |
| ExecutorInstrumentation$ExecutorRunnableInstrumentation |      48,814,752 |       6,779,428 |
| SubscriptionCancelInstrumentation                  |      42,561,353 |       8,489,875 |
| JakartaFilterInstrumentation                       |      23,363,543 |               0 |
| JakartaAsyncInstrumentation$JakartaStartAsyncInstrumentation |      20,613,139 |               0 |

OR all 3 starts with you mentioned

2026-09-04 00:02:17,662 [ForkJoinPool-2-worker-1] DEBUG co.elastic.apm.agent.bci.InstrumentationStatsLifecycleListener - Total time spent matching: 289,889,601ns
| Advice name                                        | Type ns         | Method ns       |
| SubscriptionCancelInstrumentation                  |      47,053,567 |      14,306,418 |
| ExecutorInstrumentation$ExecutorRunnableInstrumentation |      48,076,345 |       5,605,395 |
| JakartaFilterInstrumentation                       |      24,638,337 |               0 |
| JakartaAsyncInstrumentation$JakartaStartAsyncInstrumentation |      22,838,079 |               0 |

if we want to optimize the filter even further - with something like just nameEndsWith("Subscriber") we cover most of them with just this overhead.

2026-09-04 00:12:48,681 [ForkJoinPool-2-worker-1] DEBUG co.elastic.apm.agent.bci.InstrumentationStatsLifecycleListener - Total time spent matching: 259,733,930ns
| Advice name                                        | Type ns         | Method ns       |
| ExecutorInstrumentation$ExecutorRunnableInstrumentation |      62,550,712 |       5,650,035 |
| JakartaFilterInstrumentation                       |      24,774,004 |               0 |
| SubscriptionCancelInstrumentation                  |      14,498,788 |       9,413,708 |
| JakartaAsyncInstrumentation$JakartaStartAsyncInstrumentation |      20,837,849 |               0 |

the nameContains("Subscription") was there just to match it even when someone did his own implementation - bigger chance that we catch it

nameEndsWith("Subscriber") OR all 3 your package prefixes

2026-09-04 00:09:40,953 [ForkJoinPool-2-worker-1] DEBUG co.elastic.apm.agent.bci.InstrumentationStatsLifecycleListener - Total time spent matching: 281,649,260ns
| Advice name                                        | Type ns         | Method ns       |
| SubscriptionCancelInstrumentation                  |      38,772,826 |      14,850,740 |
| ExecutorInstrumentation$ExecutorRunnableInstrumentation |      48,154,398 |       5,293,119 |
| JakartaAsyncInstrumentation$JakartaStartAsyncInstrumentation |      24,675,265 |               0 |
| JakartaFilterInstrumentation                       |      23,984,791 |               0 |

the thing is that if we miss some of them, we just rely on GC to clean it up later. so it just about finding that balance I guess 😄 but at the end, every one of these will be better than it was before, where we missed all of them

}

@Override
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
Comment thread
SylvainJuge marked this conversation as resolved.
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<String> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,6 +50,8 @@ public class TracedSubscriber<T> implements CoreSubscriber<T> {

private static final ReferenceCountedMap<TracedSubscriber<?>, TraceState<?>> contextMap = GlobalTracer.get().newReferenceCountedMap();

private static final WeakMap<Subscription, WeakSet<TracedSubscriber<?>>> subscriptionMap = WeakConcurrent.buildMap();

private static final String HOOK_KEY = "elastic-apm";

private final CoreSubscriber<? super T> subscriber;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -187,6 +191,32 @@ private void doExit(boolean deactivate, String method, @Nullable TraceState<?> c
context.deactivate();
}

private void registerSubscription(Subscription subscription) {
WeakSet<TracedSubscriber<?>> subscribers = subscriptionMap.get(subscription);
if (subscribers == null) {
WeakSet<TracedSubscriber<?>> 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<TracedSubscriber<?>> subscribers = subscriptionMap.remove(subscription);
if (subscribers == null) {
return;
}
for (TracedSubscriber<?> subscriber : subscribers) {
subscriber.discardIf(true);
}
}

private void discardIf(boolean condition) {
if (!condition) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
co.elastic.apm.agent.reactor.ReactorInstrumentation
co.elastic.apm.agent.reactor.SubscriptionCancelInstrumentation
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -223,6 +226,21 @@ void contextPropagation_Flux_error() {
.verifyErrorMatches(t -> t == error);
}

@Test
void cancelledSubscription_releasedContext() {
transaction = startTestRootTransaction("root");
int initialReferenceCount = transaction.getReferenceCount();

Flux<Integer> flux = Flux.just(1, 2, 3)
.subscribeOn(SUBSCRIBE_SCHEDULER);

StepVerifier.create(flux.log())
.thenCancel()
Comment thread
jackshirazi marked this conversation as resolved.
.verify();

assertThat(transaction.getReferenceCount()).isEqualTo(initialReferenceCount);
}

@Test
void ignoreNoActiveContext() {
assertThat(tracer.getActive()).isNull();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>co.elastic.apm</groupId>
<artifactId>apm-spring-webflux</artifactId>
<version>1.56.1-SNAPSHOT</version>
</parent>

<artifactId>apm-spring-webflux-plugin-spring7</artifactId>
<name>${project.groupId}:${project.artifactId}</name>

<properties>
<!-- for licence header plugin -->
<apm-agent-parent.base.dir>${project.basedir}/../../..</apm-agent-parent.base.dir>

<animal.sniffer.skip>true</animal.sniffer.skip>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${version.spring-boot-4}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>apm-spring-webflux-spring5</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<!-- through the spring-boot dependency management, the test app will be "updated" to spring boot 4 -->
<groupId>${project.groupId}</groupId>
<artifactId>apm-spring-webflux-testapp-spring7</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>co.elastic.apm</groupId>
<artifactId>apm-spring-webflux-spring5</artifactId>
<version>${project.version}</version>
<scope>test</scope>
<type>test-jar</type>
</dependency>

<!-- required for context-propagation during tests, but only at runtime -->
<dependency>
<groupId>co.elastic.apm</groupId>
<artifactId>apm-reactor-plugin</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>co.elastic.apm</groupId>
<artifactId>apm-reactor-plugin</artifactId>
<version>${project.version}</version>
<scope>test</scope>
<type>test-jar</type>
</dependency>

<dependency>
<groupId>org.apache.ivy</groupId>
<artifactId>ivy</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
</plugin>
</plugins>
</build>

Comment thread
jackshirazi marked this conversation as resolved.
<profiles>
<profile>
<!-- Spring Boot 4 / Spring Framework 7 requires junit 6 which requires java 17 -->
<id>testing-jdk-11</id>
<activation>
<property>
<name>test_java_version</name>
<value>11</value>
</property>
</activation>
<properties>
<skipTests>true</skipTests>
</properties>
</profile>
</profiles>

</project>
Original file line number Diff line number Diff line change
@@ -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 {

}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
}
Loading
Loading