A Java instrumentation agent for Java TLS observability using eBPF integration. This agent intercepts sync and async TLS network I/O operations in Java applications and communicates with eBPF programs for distributed tracing and monitoring.
- Dynamic attach - Attach to running JVMs without code changes
- Socket-level tracing - Instruments
javax.net.ssl.SSLSocketandjava.nio.channels.SocketChanneloperations - SSL/TLS support - Intercepts
javax.net.ssl.SSLEnginefor encrypted traffic - Netty support - Instruments Netty channels for reactive applications
There are two main ways Java will create TLS traffic:
- Synchronous by using
SSLSocket. - Asynchronous by using
SSLEngineto encrypt/decrypt and some mechanism to send the data, which is typically done though socket channels. We support the native JDKSocketChannelimplementations andNetty'ssocket channels.
With this bytecode instrumentation, we intercept the TLS traffic and we ship the data to
OBI along with the connection information. We communicate with OBI via making a native C
library call to ioctl, which in turn makes a syscall. OBI attaches a kprobe to
do_vfs_ioctl and intercepts the data sent from the Java agent.
OBI cares about two main pieces of information to be able to correctly report and nest the TLS Java calls:
- The unencrypted TLS buffers.
- The connection information.
When dealing with synchronous TLS traffic (e.g. SSLSocket), the encryption and socket
communication is all done on the same thread and by the same Java class. In this case,
we simply inject a wrapper around the SSLSocket and capture the required buffers and
connection information.
Asynchronous traffic is more complex. Typically, the encryption, decryption and the communication are not done on the same thread, and definitely not done by the same class. In order to match the connection information to the unencrypted buffers, the agent injects code to do the following:
- At the time of encryption/decryption (via SSLEngine) we create keys from the encrypted text (which should be random with enough length) and map that to the unencrypted buffer.
- At the time of socket communication we have the connection information, and the
encrypted buffer. We consult the map of decrypted buffers, based on the encrypted
buffer keys and join that with the connection information. Once we have both parts
we make the same C library call to
ioctl.
The project consists of two main modules:
The core instrumentation logic using ByteBuddy for bytecode manipulation:
- Instrumentations: Socket, SocketChannel, SSLEngine, Netty
- eBPF Communication: Via JNA and
ioctlsyscalls for minimal kernel impact - Data Structures: Connection tracking, SSL session management
- Utilities: Optimized ByteBuffer extraction and manipulation
A lightweight loader that:
- Extracts the agent JAR from resources
- Loads the agent using a separate classloader to avoid conflicts with the target application
- Ensures JNA is available in the bootstrap classloader
- Handles agent attachment (both premain and agentmain)
βββββββββββββββββββββ
β Java App β
β β
β βββββββββββββ β
β β OBI Agent ββββββΌβββ Attaches via -javaagent
β βββββββ¬ββββββ β
β β β
β βββββββΌβββββββ β
β βInstrumentedβ β
β β Code β β
β βββββββ¬βββββββ β
ββββββββββΌβββββββββββ
β ioctl
βΌ
ββββββββββββ
β OBI β
ββββββββββββ
- JDK 8 or higher
- Gradle 7.0+
# Build all modules and distribution
./gradlew build
# Build only the agent
./gradlew :agent:build
# Build only the loader
./gradlew :loader:build
The final agent JAR will be located at:
build/obi-java-agent.jar
java -javaagent:/path/to/obi-java-agent.jar -jar your-application.jarUsing jattach.
jattach <PID of Java program> load instrument false "/path/to/obi-java-agent.jar"java -javaagent:/path/to/obi-java-agent.jar=debug=true \
-jar your-application.jaror for dynamic attach
jattach <PID of Java program> load instrument false "/path/to/obi-java-agent.jar=debug=true"java -javaagent:/path/to/obi-java-agent.jar=debugBB=true \
-jar your-application.jaror for dynamic attach
jattach <PID of Java program> load instrument false "/path/to/obi-java-agent.jar=debugBB=true"getInputStream()- Returns wrapped InputStreamgetOutputStream()- Returns wrapped OutputStream- Tracks connection metadata (local/remote address, ports)
read(ByteBuffer)- Single buffer readsread(ByteBuffer[])- Scatter readswrite(ByteBuffer)- Single buffer writeswrite(ByteBuffer[])- Gather writesshutdownInput- clean-upshutdownOutput- clean-upkill- clean-uptryClose- clean-up
wrap(ByteBuffer)- Encrypting outbound datawrap(ByteBuffer[])- Encrypting outbound dataunwrap(ByteBuffer)- Decrypting inbound dataunwrap(ByteBuffer[])- Decrypting inbound data- SSL session to connection mapping
wrap()- Extracts connection infounwrap()- Extracts connection info
- ByteBuddy - Bytecode manipulation and agent building
- JNA (Java Native Access) - Native library calls (ioctl)
- Caffeine - High-performance LRU for keeping track of existing connections
- Create a new class in
instrumentations/ - Implement ByteBuddy
AgentBuilder.Transformer - Register in
Agent.java - Add to re-transform list in
Agent.javafor dynamic attach - Add tests in
src/test/java/
# Run all benchmarks
./gradlew :agent:jmh
# Run specific benchmark
./gradlew :agent:jmh -Pjmh.includes=benchmarkFlattenDstByteBufferArray
# Run with GC profiling
./gradlew :agent:jmh -Pjmh.profilers=gc
# Run with memory allocation profiling
./gradlew :agent:jmh -Pjmh.profilers=gc,stackSee agent/src/jmh/java/io/opentelemetry/obi/java/instrumentations/util/BENCHMARK_README.md for detailed benchmarking documentation.
Example results (ns/op, lower is better):
Benchmark (bufferType) (bufferSize) Score
flattenDstByteBufferArray heap 64 245.3
flattenDstByteBufferArray direct 64 523.1
# Run all tests
./gradlew test
# Run tests for specific module
./gradlew :agent:test
# Run specific test class
./gradlew :agent:test --tests ByteBufferExtractorTestApache 2.0 License