Skip to content

Commit ccb8715

Browse files
feat: run Java dedup as java agent
Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
1 parent c83b581 commit ccb8715

6 files changed

Lines changed: 421 additions & 57 deletions

File tree

.github/workflows/java-agent.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Java Agent
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
verify:
11+
name: JDK ${{ matrix.java-version }}
12+
runs-on: ubuntu-latest
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
java-version: ["8", "17", "21"]
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Set up JDK ${{ matrix.java-version }}
23+
uses: actions/setup-java@v4
24+
with:
25+
distribution: temurin
26+
java-version: ${{ matrix.java-version }}
27+
cache: maven
28+
29+
- name: Build
30+
run: mvn -B -DskipTests clean verify
31+
32+
- name: Smoke test Java agent
33+
run: ./scripts/smoke-javaagent.sh

.woodpecker/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ steps:
1818
image: maven:3.9-eclipse-temurin-8
1919
commands:
2020
- mvn -B -DskipTests clean verify
21+
- ./scripts/smoke-javaagent.sh
2122

2223
build-jdk-17:
2324
image: maven:3.9-eclipse-temurin-17
2425
commands:
2526
- mvn -B -DskipTests clean verify
27+
- ./scripts/smoke-javaagent.sh
2628

2729
build-jdk-21:
2830
image: maven:3.9-eclipse-temurin-21
2931
commands:
3032
- mvn -B -DskipTests clean verify
33+
- ./scripts/smoke-javaagent.sh

README.md

Lines changed: 41 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -23,78 +23,69 @@ Coverage is collected at per-testcase granularity, not process granularity.
2323

2424
## How to Use
2525

26-
### 1. Add the SDK
26+
### 1. Download the Keploy Java Agent
2727

28-
Add `keploy-sdk` to your application:
28+
Download the `keploy-sdk` jar and keep it outside your application dependencies. The jar is a Java agent and should be attached only when you run Keploy dynamic deduplication.
2929

3030
```xml
31-
<dependency>
32-
<groupId>io.keploy</groupId>
33-
<artifactId>keploy-sdk</artifactId>
34-
<version>2.0.0</version>
35-
</dependency>
31+
<plugin>
32+
<groupId>org.apache.maven.plugins</groupId>
33+
<artifactId>maven-dependency-plugin</artifactId>
34+
<version>3.6.1</version>
35+
<executions>
36+
<execution>
37+
<id>copy-keploy-java-agent</id>
38+
<phase>package</phase>
39+
<goals>
40+
<goal>copy</goal>
41+
</goals>
42+
<configuration>
43+
<artifactItems>
44+
<artifactItem>
45+
<groupId>io.keploy</groupId>
46+
<artifactId>keploy-sdk</artifactId>
47+
<version>2.0.1</version>
48+
<outputDirectory>${project.build.directory}</outputDirectory>
49+
<destFileName>keploy-sdk.jar</destFileName>
50+
</artifactItem>
51+
</artifactItems>
52+
</configuration>
53+
</execution>
54+
</executions>
55+
</plugin>
3656
```
3757

38-
### 2. Activate the Agent
58+
The SDK no longer has to be added to `dependencies`, and application code should not import `io.keploy.*` classes for dynamic deduplication.
3959

40-
For Spring Boot, import the middleware in your application:
41-
42-
```java
43-
import io.keploy.servlet.KeployMiddleware;
44-
import org.springframework.context.annotation.Import;
45-
46-
@Import(KeployMiddleware.class)
47-
public class Application {
48-
}
49-
```
50-
51-
For servlet-based applications, register the filter early in `web.xml`:
52-
53-
```xml
54-
<filter>
55-
<filter-name>middleware</filter-name>
56-
<filter-class>io.keploy.servlet.KeployMiddleware</filter-class>
57-
</filter>
58-
<filter-mapping>
59-
<filter-name>middleware</filter-name>
60-
<url-pattern>/*</url-pattern>
61-
</filter-mapping>
62-
```
63-
64-
The middleware starts the Java dedup control server automatically.
65-
66-
For Jakarta Servlet stacks, non-servlet frameworks, or any application where the `javax.servlet` filter is not available, start the agent directly during application startup:
67-
68-
```java
69-
import io.keploy.dedup.KeployDedupAgent;
70-
71-
KeployDedupAgent.start();
72-
```
73-
74-
### 3. Run the App with the JaCoCo Java Agent
60+
### 2. Run the App with the Keploy and JaCoCo Java Agents
7561

7662
The dedup agent reads coverage in-process via JaCoCo's runtime API (`org.jacoco.agent.rt.RT.getAgent()`), so attaching the JaCoCo Java agent is the only runtime requirement in the common cases below:
7763

7864
- Maven/Gradle dev runs where application classes are under `target/classes` or `build/classes/java/main`
7965
- packaged `java -jar` runs where the application classes live inside the executable jar
8066

8167
```bash
82-
java -javaagent:/path/to/jacocoagent.jar -jar your-app.jar
68+
java \
69+
-javaagent:/path/to/keploy-sdk.jar \
70+
-javaagent:/path/to/jacocoagent.jar \
71+
-jar your-app.jar
8372
```
8473

8574
If the in-process API is unavailable (for example because the JaCoCo agent is loaded into an isolated classloader), the SDK transparently falls back to JaCoCo's TCP server mode. To use the fallback explicitly, start JaCoCo in `tcpserver` mode and set `KEPLOY_JACOCO_HOST` / `KEPLOY_JACOCO_PORT`:
8675

8776
```bash
88-
java -javaagent:/path/to/jacocoagent.jar=address=127.0.0.1,port=36320,output=tcpserver \
77+
java \
78+
-javaagent:/path/to/keploy-sdk.jar \
79+
-javaagent:/path/to/jacocoagent.jar=address=127.0.0.1,port=36320,output=tcpserver \
8980
-jar your-app.jar
9081
```
9182

92-
### 4. Replay with Keploy Enterprise
83+
### 3. Replay with Keploy Enterprise
9384

9485
Run replay with dynamic dedup enabled:
9586

9687
```bash
97-
keploy test -c "java -javaagent:/path/to/jacocoagent.jar -jar your-app.jar" \
88+
keploy test -c "java -javaagent:/path/to/keploy-sdk.jar -javaagent:/path/to/jacocoagent.jar -jar your-app.jar" \
9889
--dedup \
9990
--language java
10091
```
@@ -128,8 +119,8 @@ Without a shared `/tmp`, dedup will not work inside containers because Enterpris
128119

129120
- `KEPLOY_JACOCO_HOST`: JaCoCo TCP host used when the in-process runtime API is unavailable. Default: `127.0.0.1`
130121
- `KEPLOY_JACOCO_PORT`: JaCoCo TCP port used when the in-process runtime API is unavailable. Default: `36320`
131-
- `KEPLOY_JAVA_CLASS_DIRS`: optional comma-separated class or jar locations to analyze for executed lines when your build output lives outside the standard locations
132-
- `KEPLOY_JAVA_CLASSPATH_FALLBACK`: scans the full classpath if standard class roots and the executable jar do not provide application classes. Default: `false`
122+
- `KEPLOY_JAVA_CLASS_DIRS`: optional comma-separated class, jar, war, ear, or zip locations to analyze for executed lines when your build output lives outside the standard locations
123+
- `KEPLOY_JAVA_CLASSPATH_FALLBACK`: scans the full classpath if standard class roots and the executable archive do not provide application classes. Default: `true`
133124
- `KEPLOY_JAVA_DEDUP_DISABLED`: disables the Java dedup agent when set to `true`, `1`, or `yes`
134125

135126
## Sample
@@ -138,4 +129,4 @@ For a working reference, see the Java dedup sample in `keploy/samples-java`:
138129

139130
- `samples-java/java-dedup`
140131

141-
That sample is used in CI to validate Java dynamic dedup for JDK 8, 17, and 21 across native, Docker, and restricted Docker runs.
132+
That sample is used in CI to validate Java dynamic dedup for JDK 8, 17, and 21 across native, classpath, Docker, distroless, and restricted Docker runs.

keploy-sdk/pom.xml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,48 @@
7878

7979
<build>
8080
<plugins>
81+
<plugin>
82+
<groupId>org.apache.maven.plugins</groupId>
83+
<artifactId>maven-shade-plugin</artifactId>
84+
<version>3.5.3</version>
85+
<executions>
86+
<execution>
87+
<id>shade-java-agent</id>
88+
<phase>package</phase>
89+
<goals>
90+
<goal>shade</goal>
91+
</goals>
92+
<configuration>
93+
<createDependencyReducedPom>false</createDependencyReducedPom>
94+
<transformers>
95+
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
96+
<manifestEntries>
97+
<Premain-Class>io.keploy.dedup.KeployDedupAgent</Premain-Class>
98+
<Agent-Class>io.keploy.dedup.KeployDedupAgent</Agent-Class>
99+
<Can-Redefine-Classes>false</Can-Redefine-Classes>
100+
<Can-Retransform-Classes>false</Can-Retransform-Classes>
101+
<Implementation-Title>${project.name}</Implementation-Title>
102+
<Implementation-Version>${project.version}</Implementation-Version>
103+
<Automatic-Module-Name>io.keploy.sdk</Automatic-Module-Name>
104+
</manifestEntries>
105+
</transformer>
106+
</transformers>
107+
<filters>
108+
<filter>
109+
<artifact>*:*</artifact>
110+
<excludes>
111+
<exclude>META-INF/*.SF</exclude>
112+
<exclude>META-INF/*.DSA</exclude>
113+
<exclude>META-INF/*.RSA</exclude>
114+
<exclude>module-info.class</exclude>
115+
<exclude>META-INF/versions/*/module-info.class</exclude>
116+
</excludes>
117+
</filter>
118+
</filters>
119+
</configuration>
120+
</execution>
121+
</executions>
122+
</plugin>
81123
<plugin>
82124
<groupId>org.apache.maven.plugins</groupId>
83125
<artifactId>maven-javadoc-plugin</artifactId>

keploy-sdk/src/main/java/io/keploy/dedup/KeployDedupAgent.java

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.io.InputStream;
2525
import java.io.InputStreamReader;
2626
import java.io.OutputStream;
27+
import java.lang.instrument.Instrumentation;
2728
import java.lang.reflect.InvocationTargetException;
2829
import java.lang.reflect.Method;
2930
import java.net.InetAddress;
@@ -39,6 +40,7 @@
3940
import java.util.LinkedHashMap;
4041
import java.util.LinkedHashSet;
4142
import java.util.List;
43+
import java.util.Locale;
4244
import java.util.Map;
4345
import java.util.Set;
4446
import java.util.concurrent.atomic.AtomicBoolean;
@@ -65,11 +67,32 @@ public final class KeployDedupAgent {
6567
private static final int SOCKET_BACKLOG = 50;
6668

6769
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
70+
private static final AtomicBoolean SHUTDOWN_HOOK_REGISTERED = new AtomicBoolean(false);
6871
private static volatile CommandServer commandServer;
6972

7073
private KeployDedupAgent() {
7174
}
7275

76+
/**
77+
* JVM entrypoint used when the SDK is attached with {@code -javaagent}.
78+
*
79+
* @param agentArgs optional Java agent arguments
80+
* @param instrumentation JVM instrumentation handle
81+
*/
82+
public static void premain(String agentArgs, Instrumentation instrumentation) {
83+
start();
84+
}
85+
86+
/**
87+
* JVM entrypoint used when the SDK is attached to an already running JVM.
88+
*
89+
* @param agentArgs optional Java agent arguments
90+
* @param instrumentation JVM instrumentation handle
91+
*/
92+
public static void agentmain(String agentArgs, Instrumentation instrumentation) {
93+
start();
94+
}
95+
7396
/**
7497
* Starts the background control socket listener used by Keploy replay.
7598
*
@@ -91,6 +114,7 @@ public static boolean start() {
91114
thread.setDaemon(true);
92115
commandServer = server;
93116
thread.start();
117+
registerShutdownHook();
94118
return true;
95119
}
96120

@@ -120,6 +144,22 @@ private static boolean isDisabled() {
120144
|| isTruthy(System.getProperty("keploy.java.dedup.disabled"));
121145
}
122146

147+
private static void registerShutdownHook() {
148+
if (!SHUTDOWN_HOOK_REGISTERED.compareAndSet(false, true)) {
149+
return;
150+
}
151+
try {
152+
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
153+
@Override
154+
public void run() {
155+
KeployDedupAgent.stop();
156+
}
157+
}, "keploy-java-dedup-shutdown"));
158+
} catch (IllegalStateException ignored) {
159+
// The VM is already shutting down; the process exit will reclaim resources.
160+
}
161+
}
162+
123163
private static boolean diagnosticsEnabled() {
124164
return isTruthy(System.getenv("KEPLOY_JAVA_DEDUP_DIAGNOSTICS"))
125165
|| isTruthy(System.getProperty("keploy.java.dedup.diagnostics"));
@@ -802,13 +842,13 @@ private List<File> applicationRoots() {
802842

803843
private boolean isClasspathFallbackEnabled() {
804844
return isTruthy(envOrProperty("KEPLOY_JAVA_CLASSPATH_FALLBACK",
805-
"keploy.java.classpath.fallback", "false"));
845+
"keploy.java.classpath.fallback", "true"));
806846
}
807847

808848
private List<File> executableArchiveRoots() {
809849
LinkedHashSet<File> roots = new LinkedHashSet<>();
810850

811-
addJarRoot(roots, firstCommandToken(System.getProperty("sun.java.command", "")));
851+
addArchiveRoot(roots, firstCommandToken(System.getProperty("sun.java.command", "")));
812852

813853
String classpath = System.getProperty("java.class.path", "");
814854
if (classpath.trim().isEmpty()) {
@@ -817,12 +857,12 @@ private List<File> executableArchiveRoots() {
817857

818858
String[] parts = classpath.split(Pattern.quote(File.pathSeparator));
819859
if (parts.length == 1) {
820-
addJarRoot(roots, parts[0]);
860+
addArchiveRoot(roots, parts[0]);
821861
}
822862
return new ArrayList<>(roots);
823863
}
824864

825-
private void addJarRoot(Set<File> roots, String rawPath) {
865+
private void addArchiveRoot(Set<File> roots, String rawPath) {
826866
if (rawPath == null) {
827867
return;
828868
}
@@ -836,11 +876,19 @@ private void addJarRoot(Set<File> roots, String rawPath) {
836876
if (!file.isAbsolute()) {
837877
file = new File(System.getProperty("user.dir"), path);
838878
}
839-
if (file.isFile() && file.getName().endsWith(".jar")) {
879+
if (file.isFile() && isArchive(file)) {
840880
roots.add(file);
841881
}
842882
}
843883

884+
private boolean isArchive(File file) {
885+
String name = file.getName().toLowerCase(Locale.ROOT);
886+
return name.endsWith(".jar")
887+
|| name.endsWith(".war")
888+
|| name.endsWith(".ear")
889+
|| name.endsWith(".zip");
890+
}
891+
844892
private String firstCommandToken(String command) {
845893
if (command == null) {
846894
return "";
@@ -879,7 +927,7 @@ private List<File> classpathRoots() {
879927
for (String part : parts) {
880928
if (!part.trim().isEmpty()) {
881929
File file = new File(part.trim());
882-
if (file.isDirectory() || file.getName().endsWith(".jar")) {
930+
if (file.isDirectory() || isArchive(file)) {
883931
roots.add(file);
884932
}
885933
}
@@ -895,7 +943,7 @@ private void scanRoots(List<File> roots, Map<String, ClassEntry> output) {
895943
}
896944
if (root.isDirectory()) {
897945
scanDirectory(root, output);
898-
} else if (root.isFile() && root.getName().endsWith(".jar")) {
946+
} else if (root.isFile() && isArchive(root)) {
899947
scanJar(root, output);
900948
}
901949
}
@@ -972,6 +1020,12 @@ private String classKeyFromJarEntry(String entryName) {
9721020
private boolean shouldSkipClass(String name) {
9731021
return name.endsWith("module-info.class")
9741022
|| name.endsWith("package-info.class")
1023+
|| name.startsWith("io/keploy/dedup/")
1024+
|| name.startsWith("io/keploy/servlet/")
1025+
|| name.startsWith("org/jacoco/")
1026+
|| name.startsWith("org/objectweb/asm/")
1027+
|| name.startsWith("org/newsclub/net/unix/")
1028+
|| name.startsWith("com/google/gson/")
9751029
|| name.contains("$Mockito")
9761030
|| name.contains("Test.class");
9771031
}

0 commit comments

Comments
 (0)