Skip to content

Commit 64035d6

Browse files
authored
Add Jetty 12 and Struts 7 (Jakarta) agent plugins (#813)
Jetty 12 removed the concrete HttpChannel whose handle() the merged jetty-server plugin enhances, and moved request handling to the async, namespace-neutral core API Server#handle(Request, Response, Callback). Add a separate jetty-server-12.x plugin that enhances it and records an async entry span (finished with the response status when the wrapped Callback completes). Struts 7 relocated DefaultActionInvocation from com.opensymphony.xwork2 to org.apache.struts2 and switched to jakarta.servlet. Add a struts2-7.x plugin enhancing the new class (a distinct package from struts2-2.x avoids collision). Both reuse existing components (JettyServer, Struts2). Add plugin test scenarios covering the versions (Jetty 12.0.x/12.1.x; Struts 7.0.x/7.1.x/7.2.x) and update the supported list, plugin list and CHANGES. Each scenario drives the full RPC validation path in one trace - a server entry span, a client exit span, and a second entry span on a nested internal call carrying a cross-process ref - so both the entry and the context-extract sides of the plugin are proven, not just a lone entry span. The struts scenario uses a lightweight httpheader result instead of a JSP view to keep the cold first request under the plugin-test framework's 3s entry-service timeout. Add test/plugin/CLAUDE.md (plugin-test-first approach, the RPC/client validation paths, the one-version-per-minor rule, how the jvm/tomcat containers and GitHub Actions lanes pin their JDK/Tomcat base image, and the cold-start timeout gotcha) and document the supported jvm/tomcat containers in Plugin-test.md.
1 parent f572a76 commit 64035d6

39 files changed

Lines changed: 1638 additions & 7 deletions

File tree

.github/workflows/plugins-jdk17-test.0.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jobs:
7979
- grizzly-2.3.x-4.x-workthreadpool-scenario
8080
- jetty-11.x-scenario
8181
- jetty-10.x-scenario
82+
- jetty-12.x-scenario
8283
- spring-ai-1.x-scenario
8384
- spring-rabbitmq-scenario
8485
- graphql-20plus-scenario

.github/workflows/plugins-jdk17-test.1.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ jobs:
7272
case:
7373
- spring-6.x-scenario
7474
- spring-boot-3.x-scenario
75+
- struts2.7-scenario
7576
- resteasy-6.x-scenario
7677
- gateway-4.x-scenario
7778
- gateway-4.1.2.x-scenario

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ packages/
2323
/test/jacoco/*.exec
2424
test/jacoco
2525
.claude/settings.local.json
26+
27+
# Generated by tools/plugin/check-javaagent-plugin-list.sh (not source)
28+
/javaagent-position.txt
29+
/tools/plugin/genernate-javaagent-plugin-list.txt
30+
/tools/plugin/md-javaagent-plugin-list.txt

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Release Notes.
77

88
* Fix `plugin.http.include_http_headers` not working on Spring Boot 3.x / Jakarta EE (apache/skywalking#13938).
99
* Unify the Tomcat plugins into a single `tomcat` plugin (Tomcat 7 - 10) and the Jetty server plugins into a single `jetty-server` plugin (Jetty 9 - 11), each supporting both `javax.servlet` and `jakarta.servlet`. Breaking change: plugin names `tomcat-7.x/8.x` and `tomcat-10.x` are replaced by `tomcat`, and `jetty-server-9.x` and `jetty-server-11.x` by `jetty-server`; update `plugin.exclude_plugins` if you reference the old names.
10+
* Add a Jetty 12 server plugin (`jetty-server-12.x`). Jetty 12 removed the `HttpChannel` handle target and moved request handling to the async `Server#handle(Request, Response, Callback)` core API, so it needs a separate plugin from the merged `jetty-server`.
11+
* Add a Struts 7 plugin (`struts2-7.x`) for Jakarta Struts, whose `DefaultActionInvocation` moved to `org.apache.struts2`.
1012
* Added support for Lettuce reactive Redis commands.
1113
* Add Spring AI 1.x plugin and GenAI layer.
1214
* Fix httpclient-5.x plugin injecting sw8 propagation headers into ClickHouse HTTP requests (port 8123), causing HTTP 400. Add `PROPAGATION_EXCLUDE_PORTS` config to skip tracing (including header injection) for specified ports in the classic client interceptor.

apm-sniffer/apm-sdk-plugin/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ histogram.addValue(3);
251251

252252
The plugin test framework verifies plugin functionality using Docker containers with real services and a mock OAP backend.
253253

254+
> See `test/plugin/CLAUDE.md` for the testing philosophy: a plugin's scenario (with one-version-per-minor coverage) is the **key** test — it proves the interceptor's cast/fetch-data logic and version compatibility by running the real framework. Do **not** over-engineer with many mock-based unit tests for a plugin.
255+
254256
### Environment Requirements
255257
- MacOS/Linux
256258
- JDK 8+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<!--
2+
~ Licensed to the Apache Software Foundation (ASF) under one or more
3+
~ contributor license agreements. See the NOTICE file distributed with
4+
~ this work for additional information regarding copyright ownership.
5+
~ The ASF licenses this file to You under the Apache License, Version 2.0
6+
~ (the "License"); you may not use this file except in compliance with
7+
~ the License. You may obtain a copy of the License at
8+
~
9+
~ http://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
~
17+
-->
18+
19+
<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">
20+
<parent>
21+
<artifactId>jetty-plugins</artifactId>
22+
<groupId>org.apache.skywalking</groupId>
23+
<version>9.7.0-SNAPSHOT</version>
24+
</parent>
25+
<modelVersion>4.0.0</modelVersion>
26+
27+
<artifactId>apm-jetty-server-12.x-plugin</artifactId>
28+
<packaging>jar</packaging>
29+
30+
<name>jetty-server-12.x-plugin</name>
31+
<url>http://maven.apache.org</url>
32+
33+
<!--
34+
Jetty 12 removed the concrete org.eclipse.jetty.server.HttpChannel class that the merged
35+
jetty-server plugin enhances, and redesigned request handling around the namespace-neutral
36+
core API org.eclipse.jetty.server.Server#handle(Request, Response, Callback), which is async
37+
(the response status is only known when the Callback completes). Hence a separate plugin that
38+
does not use the servlet-commons wrapper. Covers Jetty 12.0.x and 12.1.x (same signature).
39+
-->
40+
<properties>
41+
<jetty-server.version>12.0.36</jetty-server.version>
42+
</properties>
43+
44+
<dependencies>
45+
<dependency>
46+
<groupId>org.eclipse.jetty</groupId>
47+
<artifactId>jetty-server</artifactId>
48+
<version>${jetty-server.version}</version>
49+
<scope>provided</scope>
50+
</dependency>
51+
</dependencies>
52+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.apache.skywalking.apm.plugin.jetty.v12.server;
20+
21+
import java.lang.reflect.Method;
22+
import org.apache.skywalking.apm.agent.core.context.CarrierItem;
23+
import org.apache.skywalking.apm.agent.core.context.ContextCarrier;
24+
import org.apache.skywalking.apm.agent.core.context.ContextManager;
25+
import org.apache.skywalking.apm.agent.core.context.tag.Tags;
26+
import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
27+
import org.apache.skywalking.apm.agent.core.context.trace.SpanLayer;
28+
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedInstance;
29+
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
30+
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.MethodInterceptResult;
31+
import org.apache.skywalking.apm.network.trace.component.ComponentsDefine;
32+
import org.eclipse.jetty.http.HttpFields;
33+
import org.eclipse.jetty.http.HttpURI;
34+
import org.eclipse.jetty.server.Request;
35+
import org.eclipse.jetty.server.Response;
36+
import org.eclipse.jetty.util.Callback;
37+
38+
/**
39+
* Enhances the core, namespace-neutral {@code org.eclipse.jetty.server.Server#handle(Request, Response,
40+
* Callback)} — the single top-level entry invoked once per request in Jetty 12. Handling is async, so
41+
* the entry span is prepared for async in beforeMethod, its synchronous segment ends in afterMethod, and
42+
* it is finished (with the response status) when the wrapped {@link SwCallback} completes.
43+
*/
44+
public class HandleInterceptor implements InstanceMethodsAroundInterceptor {
45+
46+
private static final ThreadLocal<SwCallback> HOLDER = new ThreadLocal<SwCallback>();
47+
48+
@Override
49+
public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
50+
MethodInterceptResult result) throws Throwable {
51+
Request request = (Request) allArguments[0];
52+
Response response = (Response) allArguments[1];
53+
Callback callback = (Callback) allArguments[2];
54+
55+
ContextCarrier contextCarrier = new ContextCarrier();
56+
HttpFields headers = request.getHeaders();
57+
CarrierItem next = contextCarrier.items();
58+
while (next.hasNext()) {
59+
next = next.next();
60+
next.setHeadValue(headers.get(next.getHeadKey()));
61+
}
62+
63+
HttpURI uri = request.getHttpURI();
64+
AbstractSpan span = ContextManager.createEntrySpan(uri.getPath(), contextCarrier);
65+
Tags.URL.set(span, uri.asString());
66+
Tags.HTTP.METHOD.set(span, request.getMethod());
67+
span.setComponent(ComponentsDefine.JETTY_SERVER);
68+
SpanLayer.asHttp(span);
69+
span.prepareForAsync();
70+
71+
SwCallback swCallback = new SwCallback(callback, response, span);
72+
allArguments[2] = swCallback;
73+
HOLDER.set(swCallback);
74+
}
75+
76+
@Override
77+
public Object afterMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
78+
Object ret) throws Throwable {
79+
SwCallback swCallback = HOLDER.get();
80+
HOLDER.remove();
81+
ContextManager.stopSpan();
82+
// ret == true: handled; the wrapped callback fires on response completion and finishes the span.
83+
// ret == false: not handled — Jetty writes 404 on the original callback, bypassing ours.
84+
// ret == null: handle() threw (see handleMethodException) — Jetty writes 500.
85+
// For the latter two the wrapped callback never fires and the status is written only after
86+
// handle() returns, so finish here with the status Jetty's contract guarantees.
87+
if (swCallback != null && !Boolean.TRUE.equals(ret)) {
88+
swCallback.finishWithStatus(ret == null ? 500 : 404);
89+
}
90+
return ret;
91+
}
92+
93+
@Override
94+
public void handleMethodException(EnhancedInstance objInst, Method method, Object[] allArguments,
95+
Class<?>[] argumentsTypes, Throwable t) {
96+
AbstractSpan span = ContextManager.activeSpan();
97+
span.log(t);
98+
span.errorOccurred();
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.apache.skywalking.apm.plugin.jetty.v12.server;
20+
21+
import java.util.concurrent.atomic.AtomicBoolean;
22+
import org.apache.skywalking.apm.agent.core.context.tag.Tags;
23+
import org.apache.skywalking.apm.agent.core.context.trace.AbstractSpan;
24+
import org.eclipse.jetty.server.Response;
25+
import org.eclipse.jetty.util.Callback;
26+
import org.eclipse.jetty.util.thread.Invocable;
27+
28+
/**
29+
* Wraps the {@link Callback} passed to {@code Server#handle}. Jetty 12 request handling is async — the
30+
* response status is only known when the callback completes on the (possibly different) response thread.
31+
* On completion this reads the status, records it on the entry span and finishes the async span. It
32+
* delegates every method to the original callback so Jetty's threading model is preserved.
33+
*/
34+
public class SwCallback implements Callback {
35+
36+
private final Callback delegate;
37+
private final Response response;
38+
private final AbstractSpan span;
39+
private final AtomicBoolean finished = new AtomicBoolean();
40+
41+
public SwCallback(Callback delegate, Response response, AbstractSpan span) {
42+
this.delegate = delegate;
43+
this.response = response;
44+
this.span = span;
45+
}
46+
47+
@Override
48+
public void succeeded() {
49+
finishOnce();
50+
delegate.succeeded();
51+
}
52+
53+
@Override
54+
public void failed(Throwable x) {
55+
if (finished.compareAndSet(false, true)) {
56+
span.log(x);
57+
span.errorOccurred();
58+
Tags.HTTP_RESPONSE_STATUS_CODE.set(span, response.getStatus());
59+
span.asyncFinish();
60+
}
61+
delegate.failed(x);
62+
}
63+
64+
@Override
65+
public Invocable.InvocationType getInvocationType() {
66+
return delegate.getInvocationType();
67+
}
68+
69+
/**
70+
* Records the response status and finishes the async span exactly once. Safe to call from either the
71+
* callback completion or the interceptor's afterMethod fallback (not-handled / exception paths).
72+
*/
73+
void finishOnce() {
74+
if (finished.compareAndSet(false, true)) {
75+
int statusCode = response.getStatus();
76+
Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
77+
if (statusCode >= 400) {
78+
span.errorOccurred();
79+
}
80+
span.asyncFinish();
81+
}
82+
}
83+
84+
/**
85+
* Finishes the async span exactly once with an explicit status. Used for the not-handled / exception
86+
* fallback paths where Jetty writes the error status on the original callback after {@code handle()}
87+
* returns — so the wrapped callback never fires and {@link Response#getStatus()} is still 0 (uncommitted).
88+
*/
89+
void finishWithStatus(int statusCode) {
90+
if (finished.compareAndSet(false, true)) {
91+
Tags.HTTP_RESPONSE_STATUS_CODE.set(span, statusCode);
92+
if (statusCode >= 400) {
93+
span.errorOccurred();
94+
}
95+
span.asyncFinish();
96+
}
97+
}
98+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.apache.skywalking.apm.plugin.jetty.v12.server.define;
20+
21+
import java.util.Collections;
22+
import java.util.List;
23+
import net.bytebuddy.description.method.MethodDescription;
24+
import net.bytebuddy.matcher.ElementMatcher;
25+
import org.apache.skywalking.apm.agent.core.plugin.WitnessMethod;
26+
import org.apache.skywalking.apm.agent.core.plugin.interceptor.ConstructorInterceptPoint;
27+
import org.apache.skywalking.apm.agent.core.plugin.interceptor.InstanceMethodsInterceptPoint;
28+
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.ClassInstanceMethodsEnhancePluginDefine;
29+
import org.apache.skywalking.apm.agent.core.plugin.match.ClassMatch;
30+
31+
import static net.bytebuddy.matcher.ElementMatchers.named;
32+
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
33+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
34+
import static org.apache.skywalking.apm.agent.core.plugin.match.NameMatch.byName;
35+
36+
/**
37+
* Enhances {@code org.eclipse.jetty.server.Server#handle(Request, Response, Callback)} by
38+
* {@code HandleInterceptor}. Only Jetty 12+ declares this three-argument, {@code Callback}-terminated
39+
* signature, so the witness method both activates the plugin exclusively on Jetty 12 and keeps it from
40+
* overlapping the merged jetty-server plugin (which enhances the Jetty 9/10/11 {@code HttpChannel},
41+
* removed in Jetty 12).
42+
*/
43+
public class Jetty12ServerInstrumentation extends ClassInstanceMethodsEnhancePluginDefine {
44+
45+
private static final String ENHANCE_CLASS = "org.eclipse.jetty.server.Server";
46+
private static final String ENHANCE_METHOD = "handle";
47+
private static final String CALLBACK_CLASS = "org.eclipse.jetty.util.Callback";
48+
private static final String INTERCEPT_CLASS = "org.apache.skywalking.apm.plugin.jetty.v12.server.HandleInterceptor";
49+
50+
@Override
51+
protected List<WitnessMethod> witnessMethods() {
52+
return Collections.singletonList(new WitnessMethod(
53+
ENHANCE_CLASS,
54+
named(ENHANCE_METHOD).and(takesArgument(2, named(CALLBACK_CLASS)))
55+
));
56+
}
57+
58+
@Override
59+
public ConstructorInterceptPoint[] getConstructorsInterceptPoints() {
60+
return new ConstructorInterceptPoint[0];
61+
}
62+
63+
@Override
64+
public InstanceMethodsInterceptPoint[] getInstanceMethodsInterceptPoints() {
65+
return new InstanceMethodsInterceptPoint[] {
66+
new InstanceMethodsInterceptPoint() {
67+
@Override
68+
public ElementMatcher<MethodDescription> getMethodsMatcher() {
69+
return named(ENHANCE_METHOD).and(takesArguments(3)).and(takesArgument(2, named(CALLBACK_CLASS)));
70+
}
71+
72+
@Override
73+
public String getMethodsInterceptor() {
74+
return INTERCEPT_CLASS;
75+
}
76+
77+
@Override
78+
public boolean isOverrideArgs() {
79+
return true;
80+
}
81+
}
82+
};
83+
}
84+
85+
@Override
86+
protected ClassMatch enhanceClass() {
87+
return byName(ENHANCE_CLASS);
88+
}
89+
}

0 commit comments

Comments
 (0)