All articles
2026-09-27•18 min read•JVM

Building a lightweight method tracer with Byte Buddy — from runtime subclassing to a Java agent

Trace Java methods without touching their source, using Byte Buddy — runtime subclasses, method delegation, inlined advice, and a premain Java agent, all verified.

pC
Prashant Chaturvedi
Engineer

You inherit a service that calls an external system, and someone asks the deceptively simple question: how long does each call take, and how often does it fail? The honest answer should take ten minutes, but the service has no metrics, no tracing, and its construction is buried three frameworks deep. You could add timing code by hand — and then do it again for the next service, and the next.

This article builds the small tool instead: a method tracer that records the method name, execution time, success or failure, and a correlation ID — without changing the service’s source code. You will build it three ways, each motivated by the limitations of the last: a runtime subclass with MethodDelegation, the same tracer using inlined advice, and finally a premain Java agent built on AgentBuilder that transforms the class as it loads. By the end you will also attach the agent to a running JVM and retransform an already-loaded class, which is where the interesting constraints live.

The target output looks like this:

Expected output
[trace] ok in.o612.eng.tracedemo.service.OrderService.quote 3291 µs cid=req-1042
[trace] ok in.o612.eng.tracedemo.service.OrderService.charge 13817 µs cid=req-1042
charged: order O-100 priced at 49.90
[trace] fail in.o612.eng.tracedemo.service.OrderService.quote 49 µs cid=req-1043 error=IllegalStateException
caller saw: unknown order O-999

Everything below was compiled and executed on JDK 21 with Byte Buddy 1.18.14, the current release at the time of writing. Byte Buddy’s API is stable, but the version-sensitive details — matcher names, annotation attributes, agent builder interfaces — were checked against that release specifically.

Three ways to change what a method does

Before writing code, it is worth being precise about what “instrument a method” can mean, because Byte Buddy supports three distinct operations and they differ in when they take effect and which objects they reach.

Subclassing generates a new class — OrderService$ByteBuddy$KGpCWwck or similar — that extends your class and overrides matched methods. The original class file is untouched. Only instances of the generated class are intercepted; a new OrderService() elsewhere in the program runs the original code, and objects created before the subclass exists are unaffected. Calls reach the original implementation through super, exactly as if you had written a subclass by hand.

Redefinition replaces the byte array of a class before it is loaded — a ClassFileTransformer registered through java.lang.instrument sees every class file as it enters a class loader and can substitute new bytes. Every instance of the class gets the new behavior, but the transformation must happen before the class is first loaded.

Retransformation replaces the bytecode of a class that is already loaded. This is the only mechanism that changes code for objects that already exist, and it comes with the JVM’s hard constraint: the new version may not add, remove, or rename fields or methods, change signatures, or alter the class hierarchy. Method bodies may change freely. (The restriction comes from Instrumentation.retransformClasses, not Byte Buddy — see the java.lang.instrument javadoc.) One more subtlety: methods already on the call stack continue executing the old bytecode until they return.

ApproachAffects existing instances?Can touch already-loaded classes?Structural freedom
SubclassNo — new instances onlyN/AFull — new class, any shape
Redefinition (pre-load)All instances, once loadedNo — runs before loadingFull — but schema fixed at load
Retransformation (post-load)Yes — class is replaced in placeYesMethod bodies only

That table is the spine of the article. We will hit its limits in order.

The sample service

The example is deliberately small. OrderService has one method that succeeds and one that throws, plus a wrinkle: charge calls quote internally through this, which will matter when we discuss what “interception” actually covers.

src/main/java/in/o612/eng/tracedemo/service/OrderService.java
package in.o612.eng.tracedemo.service;
import java.math.BigDecimal;
import java.util.Map;
public class OrderService {
private final Map<String, BigDecimal> prices =
Map.of("O-100", new BigDecimal("49.90"), "O-200", new BigDecimal("12.50"));
public String quote(String orderId) {
BigDecimal price = prices.get(orderId);
if (price == null) {
throw new IllegalStateException("unknown order " + orderId);
}
return "order " + orderId + " priced at " + price;
}
public String charge(String orderId) {
// Internal call through `this` — virtual dispatch still applies.
return "charged: " + quote(orderId);
}
}

TraceRecorder is the entire runtime of the tool — time the call, emit a line, read a correlation ID from a ThreadLocal. The thread-local belongs to the caller: whoever runs a unit of work sets it, and clears it in a finally so it cannot leak into the next task on a pooled thread. The tracer only reads it.

src/main/java/in/o612/eng/tracer/TraceRecorder.java
package in.o612.eng.tracer;
public final class TraceRecorder {
/** Populated and cleared by the caller's unit of work; the tracer only reads it. */
public static final ThreadLocal<String> CORRELATION_ID = new ThreadLocal<>();
private TraceRecorder() {
}
public static long enter() {
return System.nanoTime();
}
public static void exit(String signature, long startNanos, Throwable thrown) {
long micros = (System.nanoTime() - startNanos) / 1_000L;
String cid = CORRELATION_ID.get();
String status = thrown == null ? "ok " : "fail";
String detail = thrown == null ? "" : " error=" + thrown.getClass().getSimpleName();
String context = cid == null ? "" : " cid=" + cid;
System.out.printf("[trace] %s %s %d µs%s%s%n", status, signature, micros, context, detail);
}
}

System.nanoTime() is the right clock here — it is monotonic and cheap; wall-clock time (System.currentTimeMillis) can move backwards under NTP corrections and would corrupt durations.

Project layout

A single Maven module is enough. The same jar will later double as the agent jar.

Project layout
bytebuddy-tracer/
├── pom.xml
└── src/main/java/in/o612/eng/
├── tracedemo/
│ ├── Main.java
│ ├── SubclassDemo.java
│ ├── AdviceDemo.java
│ ├── AttachDemo.java
│ └── service/OrderService.java
└── tracer/
├── TraceRecorder.java
├── TimingInterceptor.java
├── TracingAdvice.java
└── TracingAgent.java
pom.xml
<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/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>in.o612.eng</groupId>
<artifactId>bytebuddy-tracer</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.18.14</version>
</dependency>
<!-- Only needed for the runtime-attach demo later. -->
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.18.14</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>in.o612.eng.tracer.TracingAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

net.bytebuddy:byte-buddy is the code-generation library itself; byte-buddy-agent is the separate artifact for attaching to a running JVM (it is not needed for -javaagent usage — premain receives the Instrumentation instance for free).

Stage 1 — a subclassing tracer

The simplest thing that works: generate a subclass, override the public methods, and delegate each call to a tracing interceptor.

src/main/java/in/o612/eng/tracedemo/SubclassDemo.java
package in.o612.eng.tracedemo;
import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy;
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
import static net.bytebuddy.matcher.ElementMatchers.not;
import in.o612.eng.tracedemo.service.OrderService;
import in.o612.eng.tracer.TimingInterceptor;
import in.o612.eng.tracer.TraceRecorder;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
import net.bytebuddy.implementation.MethodDelegation;
public final class SubclassDemo {
public static void main(String[] args) throws Exception {
OrderService traced = new ByteBuddy()
.subclass(OrderService.class)
.method(isPublic().and(not(isDeclaredBy(Object.class))))
.intercept(MethodDelegation.to(TimingInterceptor.class))
.make()
.load(OrderService.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
.getLoaded()
.getDeclaredConstructor()
.newInstance();
System.out.println("traced instance class: " + traced.getClass().getName());
run(traced);
System.out.println("-- plain instance below --");
run(new OrderService());
}
static void run(OrderService service) {
TraceRecorder.CORRELATION_ID.set("req-1042");
try {
System.out.println(service.charge("O-100"));
} finally {
TraceRecorder.CORRELATION_ID.remove();
}
TraceRecorder.CORRELATION_ID.set("req-1043");
try {
service.quote("O-999");
} catch (IllegalStateException expected) {
System.out.println("caller saw: " + expected.getMessage());
} finally {
TraceRecorder.CORRELATION_ID.remove();
}
}
}
src/main/java/in/o612/eng/tracer/TimingInterceptor.java
package in.o612.eng.tracer;
import java.lang.reflect.Method;
import java.util.concurrent.Callable;
import net.bytebuddy.implementation.bind.annotation.AllArguments;
import net.bytebuddy.implementation.bind.annotation.Origin;
import net.bytebuddy.implementation.bind.annotation.RuntimeType;
import net.bytebuddy.implementation.bind.annotation.SuperCall;
public final class TimingInterceptor {
private TimingInterceptor() {
}
@RuntimeType
public static Object trace(@Origin Method method,
@AllArguments Object[] args,
@SuperCall Callable<?> superCall) {
long start = TraceRecorder.enter();
try {
Object result = superCall.call();
TraceRecorder.exit(method.getName(), start, null);
return result;
} catch (Throwable t) {
TraceRecorder.exit(method.getName(), start, t);
throw rethrow(t);
}
}
// Checked exceptions are a javac concept; at the bytecode level this rethrows
// the original Throwable unchanged, so the caller sees identical semantics.
@SuppressWarnings("unchecked")
private static <E extends Throwable> RuntimeException rethrow(Throwable t) throws E {
throw (E) t;
}
}

Running it produces:

SubclassDemo output
traced instance class: in.o612.eng.tracedemo.service.OrderService$ByteBuddy$KGpCWwck
[trace] ok quote 4209 µs cid=req-1042
[trace] ok charge 13813 µs cid=req-1042
charged: order O-100 priced at 49.90
[trace] fail quote 58 µs cid=req-1043 error=IllegalStateException
caller saw: unknown order O-999
-- plain instance below --
charged: order O-100 priced at 49.90
caller saw: unknown order O-999

Several things worth noticing in that output:

  • The internal charge → quote call is traced. Because charge invokes this.quote(...), and this is an instance of the generated subclass, virtual dispatch lands on the override. Subclass interception does capture self-calls — that is a difference from wrapper/proxy schemes that sit outside the object.
  • The exception propagates with identical semantics. caller saw: unknown order O-999 — the caller catches the same IllegalStateException it would have seen without instrumentation.
  • The plain new OrderService() produces no trace lines. The generated subclass is a different class; existing construction sites are untouched. This is the fundamental limit we will dissolve later.

ClassLoadingStrategy.Default.WRAPPER loads the generated class in a new class loader beneath the one that loaded OrderService. That is the safest default: it avoids mutating the application class loader, and the generated class resolves TimingInterceptor and TraceRecorder through normal parent delegation. The alternative, INJECTION, defines the class in the same loader — necessary if you intercept package-private or protected methods, which WRAPPER cannot see across the loader boundary. All our targets are public, so WRAPPER suffices.

What Byte Buddy actually generated: a class extending OrderService with a synthetic name, each matched method overridden to build the argument array, call TimingInterceptor.trace(...), and — this is the important part — an auxiliary super-call method that invokes OrderService.quote via invokespecial, wrapped in the Callable passed to superCall.call(). The interceptor is therefore a genuine method call away from the original code, not bytecode merged into it.

What MethodDelegation does — and what it costs

The interceptor’s parameters are not magic; they are bound by a resolver that matches each parameter to what the instrumented method can supply, using the annotations:

  • @SuperCall Callable<?> superCall — invokes the original (super) implementation. Returning a Callable rather than invoking eagerly is what lets the interceptor wrap the call in try/catch.
  • @Origin Method method — the java.lang.reflect.Method of the instrumented method. @Origin can also inject String templates ("#t.#m" produces com.foo.Bar.baz), Class, MethodHandle, and several other shapes.
  • @AllArguments Object[] args — the invocation arguments, boxed into an array. We do not use them yet, but they are the hook for logging inputs — and for accidentally logging a customer’s password, which is why production tracers filter arguments rather than dumping them.

Two binding rules bit me while writing this example, and both produce the same terse error — None of [...] allows for delegation from ...:

  1. @RuntimeType is required for a return-type mismatch. The interceptor returns Object, but quote returns String. Without @RuntimeType, Byte Buddy requires the delegate’s return type to be assignable to the instrumented method’s return type — Object is not assignable to String. The annotation tells the binder to insert a checkcast at the call site. (The cast is checked by the JVM, so a wrong return type fails with ClassCastException, not silent corruption.)
  2. Declared exceptions are checked. An early version of TimingInterceptor declared throws Throwable — and binding failed again, because the binder validates that every checked exception the delegate declares is compatible with the target method’s throws clause. quote declares none, so the delegate may declare none either. That is why rethrow uses the generic sneaky-throw: <E extends Throwable> RuntimeException rethrow(Throwable t) throws E compiles to a plain athrow — the JVM does not verify checked exceptions at the bytecode level, so the original exception escapes with its exact type and stack trace. Catching Throwable and rethrowing via throw t would not compile, and wrapping it in RuntimeException would change the failure contract.

The cost model of delegation: each intercepted call allocates the argument array (@AllArguments) and — depending on Byte Buddy’s caching — a Callable for the super-call, executes a reflective-style dispatch through the interceptor, and box/unboxes primitives into the Object[]. The interceptor frame also sits on the stack between caller and target. None of that is fatal at low call rates, but on a hot path it is real, measurable garbage. Delegation’s strength is flexibility — full reflection metadata, replaceable arguments (@Morph, @DefaultCall, @This), pluggable interceptors — not minimal overhead.

And the structural limits remain: only overridable methods can be intercepted (final, private, static, and constructors cannot be overridden), the target class itself must not be final, and — the reason we are not done — only instances of the generated subclass are instrumented.

Stage 2 — Advice: the same tracer, inlined

Advice works differently. Instead of generating a method that calls out to a delegate, Byte Buddy copies the advice’s bytecode into the instrumented method — at entry and at exit — around the original body. Nothing delegates; there is no interceptor frame, no Callable, no Method object lookup. The same TraceRecorder from stage 1 carries over unchanged.

src/main/java/in/o612/eng/tracer/TracingAdvice.java
package in.o612.eng.tracer;
import net.bytebuddy.asm.Advice;
public final class TracingAdvice {
private TracingAdvice() {
}
@Advice.OnMethodEnter
static long enter() {
return TraceRecorder.enter();
}
@Advice.OnMethodExit(onThrowable = Throwable.class)
static void exit(@Advice.Origin("#t.#m") String signature,
@Advice.Enter long start,
@Advice.Thrown Throwable thrown) {
TraceRecorder.exit(signature, start, thrown);
}
}

Three bindings do the work:

  • @Advice.OnMethodEnter runs at method entry; its non-void return value can be passed to the exit advice via @Advice.Enter — this is how start travels from entry to exit without any thread-local bookkeeping. Because Byte Buddy stores it in a new local variable of the instrumented method, it is correct under recursion and concurrency with zero extra work.
  • @Advice.OnMethodExit(onThrowable = Throwable.class) is the critical piece for correctness: the exit advice runs whether the method returns normally or throws, and the exception is rethrown after the advice completes — the catch block around the method body lives in bytecode, so the caller’s view of the exception is unchanged. @Advice.Thrown binds that throwable (null on normal return). Without onThrowable, the exit advice is skipped on exceptional exit and failed calls would vanish from the trace.
  • @Advice.Origin("#t.#m") injects a constant string — DeclaringType.methodName — resolved at instrumentation time. It costs nothing at runtime.

The demo is identical except for the implementation strategy:

src/main/java/in/o612/eng/tracedemo/AdviceDemo.java (partial)
OrderService traced = new ByteBuddy()
.subclass(OrderService.class)
.method(isPublic().and(not(isDeclaredBy(Object.class))))
.intercept(Advice.to(TracingAdvice.class))
.make()
.load(OrderService.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
.getLoaded()
.getDeclaredConstructor()
.newInstance();

What “inlined” concretely means is easiest to see in the generated bytecode. Running javap -c on the transformed OrderService.quote:

javap -c on the instrumented quote() (abridged)
public java.lang.String quote(java.lang.String);
Code:
0: invokestatic TraceRecorder.enter:()J
6: lstore_2 // @Advice.Enter start
...
14: <original method body>
74: astore 5 // catch Throwable -> thrown
79: ldc "in.o612.eng.tracedemo.service.OrderService.quote"
81: lload_2
82: aload 5
84: invokestatic TraceRecorder.exit:(Ljava/lang/String;JLjava/lang/Throwable;)V
90: aload 5
92: ifnull 98
95: aload 5
97: athrow // rethrow original exception
98: aload 4
100: areturn
Exception table:
from to target type
14 66 74 Class java/lang/Throwable

The signature string is an ldc constant; the catch handler captures Throwable into a local, the shared exit path calls TraceRecorder.exit, and then either athrow rethrows the captured throwable or areturn returns the captured result. Entry timing went into local slot 2. There is no delegation anywhere in this bytecode — the advice became part of the method.

Is Advice “faster” than delegation here? Marginally — it removes the interceptor frame, the Callable, and the argument array we never used. But the honest reason it is the better fit at this stage is structural, not benchmarked: inlining is what makes transformation of already-loaded classes possible at all. A delegate requires auxiliary methods (the super-call plumbing) that retransformation cannot add; inlined advice only touches method bodies. Advice is also more constrained — it cannot change the method signature, it binds fields of the target class with @Advice.FieldValue, and by default exceptions thrown by the advice itself propagate to the caller. If a tracer bug must never break the traced application, add suppress = Throwable.class to the advice annotations — a deliberate choice: instrumentation failures become invisible, so pair it with a debug listener.

Two more properties worth internalizing, because they will matter in the agent stage:

  • The inlined code executes in the instrumented class’s class-loader context. The invokestatic TraceRecorder.exit inside OrderService.quote is resolved by OrderService’s class loader — so TraceRecorder must be visible to that loader, not to the agent’s. Keep this in mind; it is the cause of the most common agent failure.
  • Advice classes are templates, not delegates. TracingAdvice’s methods are never called — only copied. Anything the advice references (TraceRecorder) is referenced by the copied code, so it must exist where the instrumented class lives.

Stage 3 — packaging as a Java agent

Now the payoff: trace OrderService without changing how it is constructed. A Java agent is a jar with a Premain-Class manifest entry; the JVM calls premain(String, Instrumentation) before main, giving the agent a ClassFileTransformer hook into every class load.

src/main/java/in/o612/eng/tracer/TracingAgent.java
package in.o612.eng.tracer;
import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy;
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
import static net.bytebuddy.matcher.ElementMatchers.isSynthetic;
import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith;
import static net.bytebuddy.matcher.ElementMatchers.not;
import java.lang.instrument.Instrumentation;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
public final class TracingAgent {
public static void premain(String agentArgs, Instrumentation instrumentation) {
new AgentBuilder.Default()
.ignore(nameStartsWith("net.bytebuddy.")
.or(nameStartsWith("in.o612.eng.tracer."))
.or(isSynthetic()))
.type(nameStartsWith("in.o612.eng.tracedemo.service."))
.transform(new AgentBuilder.Transformer.ForAdvice()
.include(TracingAgent.class.getClassLoader())
.advice(isMethod()
.and(isPublic())
.and(not(isDeclaredBy(Object.class))),
TracingAdvice.class.getName()))
.installOn(instrumentation);
}
}

Dissecting the builder:

  • .ignore(...) runs before the type matcher and excludes three categories: Byte Buddy’s own classes, the tracer itself (in.o612.eng.tracer), and synthetic types. Our type matcher is narrow enough that none of these would match anyway, but the ignore is cheap insurance — the moment someone widens in.o612.eng.tracedemo.service. to in.o612.eng., the tracer’s own classes come into scope, and an instrumented TraceRecorder.exit called from inlined advice inside TraceRecorder.exit is infinite recursion. (Byte Buddy additionally guards against circular transformation with a built-in circularity lock, but that protects the transformation pipeline, not your matchers.)
  • .type(nameStartsWith("in.o612.eng.tracedemo.service.")) is the scoping decision. In a real service this would be something like nameStartsWith("com.acme.billing.") or isAnnotatedWith(RestController.class). Resist ElementMatchers.any() — every discovered class pays the price of evaluating your transformer, and instrumenting JDK internals is a reliable way to produce confusing NoClassDefFoundErrors during startup.
  • AgentBuilder.Transformer.ForAdvice applies advice by class name, reading the advice class’s bytecode through .include(...)’s class-loader-based locator. This is the idiomatic agent pattern: the advice class is used as a template without being prematurely loaded or linked.
  • The method matcher isMethod().and(isPublic()).and(not(isDeclaredBy(Object.class))) excludes constructors (isMethod matches only methods, not <init>/<clinit>) and the Object trio.

The demo Main is deliberately boring — plain construction, no Byte Buddy imports:

src/main/java/in/o612/eng/tracedemo/Main.java
package in.o612.eng.tracedemo;
import in.o612.eng.tracedemo.service.OrderService;
import in.o612.eng.tracer.TraceRecorder;
public final class Main {
public static void main(String[] args) {
OrderService service = new OrderService();
TraceRecorder.CORRELATION_ID.set("req-1042");
try {
System.out.println(service.charge("O-100"));
} finally {
TraceRecorder.CORRELATION_ID.remove();
}
TraceRecorder.CORRELATION_ID.set("req-1043");
try {
service.quote("O-999");
} catch (IllegalStateException expected) {
System.out.println("caller saw: " + expected.getMessage());
} finally {
TraceRecorder.CORRELATION_ID.remove();
}
}
}

Build and run:

Build and run with the agent
mvn package
java -javaagent:target/bytebuddy-tracer-1.0.0.jar \
-cp target/bytebuddy-tracer-1.0.0.jar \
in.o612.eng.tracedemo.Main
Agent run output
[trace] ok in.o612.eng.tracedemo.service.OrderService.quote 3291 µs cid=req-1042
[trace] ok in.o612.eng.tracedemo.service.OrderService.charge 13817 µs cid=req-1042
charged: order O-100 priced at 49.90
[trace] fail in.o612.eng.tracedemo.service.OrderService.quote 49 µs cid=req-1043 error=IllegalStateException
caller saw: unknown order O-999

Same output as stage 1, but new OrderService() — unmodified — is traced. Two subtle differences worth noticing:

  • #t now prints the real class name. Under subclassing, @Origin resolved to OrderService$ByteBuddy$...; under transformation the instrumented class is OrderService, which is what you want in logs.
  • The tracer classes resolve through the application class loader. -javaagent appends the agent jar to the system classpath, so in.o612.eng.tracer.* classes are loadable by the same loader that owns OrderService, and the inlined invokestatic resolves. If you instrumented a class loaded by the bootstrap loader (a java.* class, say), that loader cannot see the classpath — the inlined call would fail with NoClassDefFoundError. Real agents solve this by injecting helper classes into the bootstrap loader (ClassInjector.UsingInstrumentation, wrapped by AgentBuilder’s bootstrap-injection support); for an application-scoped tracer, staying on the system classpath is enough.

Correlation ID lifecycle. CORRELATION_ID.set(...) enters the context at the boundary of the unit of work; remove() in finally ends it. The tracer reads the thread-local when emitting the record — the value travels into the trace output and is then dropped by the caller’s cleanup. In a Spring service the boundary would be a HandlerInterceptor or servlet filter that the agent also instruments; the demo simulates that boundary inline. What the demo does not do is propagate context across threads — the quote/charge calls run on the calling thread, so a plain ThreadLocal suffices; once work hops to an executor, you need context propagation (transmittable thread-locals, or capturing context at task-submission time), which is squarely out of scope here.

When the class is already loaded: attach and retransformation

premain only sees classes loaded after the JVM hands it control. For a running process — a Spring Boot service you cannot restart — agents can also attach dynamically via agentmain or, in-process, ByteBuddyAgent.install() from byte-buddy-agent. The AgentBuilder gains one new ingredient: RedefinitionStrategy.RETRANSFORMATION, which additionally scans for already-loaded classes matching the type matcher and calls Instrumentation.retransformClasses on them.

src/main/java/in/o612/eng/tracedemo/AttachDemo.java (partial)
OrderService service = new OrderService();
service.quote("O-100"); // untraced — class already loaded and executed
var instrumentation = ByteBuddyAgent.install();
new AgentBuilder.Default()
.ignore(nameStartsWith("net.bytebuddy.")
.or(nameStartsWith("in.o612.eng.tracer."))
.or(isSynthetic()))
.type(nameStartsWith("in.o612.eng.tracedemo.service."))
.transform(new AgentBuilder.Transformer.ForAdvice()
.include(AttachDemo.class.getClassLoader())
.advice(isMethod().and(isPublic()).and(not(isDeclaredBy(Object.class))),
TracingAdvice.class.getName()))
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE)
.installOn(instrumentation);
service.quote("O-200"); // traced — same instance, new bytecode
AttachDemo output
before: order O-100 priced at 49.90
[trace] ok in.o612.eng.tracedemo.service.OrderService.quote 407 µs
after: order O-200 priced at 12.50

The same service instance — created before any instrumentation — now traces. There is one non-obvious line: InitializationStrategy.NoOp.INSTANCE. Without it, the retransformation fails with class redefinition failed: attempted to add a method. The dump (Byte Buddy writes generated classes to disk with -Dnet.bytebuddy.dump=/path) shows why: the default initialization strategy adds a <clinit> to the transformed class that calls net.bytebuddy.dynamic.Nexus.initialize(...) — Byte Buddy’s mechanism for registering initialization callbacks on classes living in loaders it does not control. A class initializer counts as a method, and retransformation may not add methods. NoOp drops the hook; our advice needs no auxiliary types, so nothing is lost.

This is the general shape of retransformation bugs: not the advice (it inlines cleanly), but everything around it — initializers, auxiliary types, delegation plumbing — that must fit the JVM’s “same schema” rule.

How the pieces compose

class bytes

JVM startup

premain()

AgentBuilder registers

ClassFileTransformer

Main loads

AppClassLoader

transform:

type + method matchers

Advice inlined into

quote / charge

service.charge()

TraceRecorder

reads CORRELATION_ID

class bytes

JVM startup

premain()

AgentBuilder registers

ClassFileTransformer

Main loads

AppClassLoader

transform:

type + method matchers

Advice inlined into

quote / charge

service.charge()

TraceRecorder

reads CORRELATION_ID

The pipeline has four independent knobs, and every stage above moved exactly one of them:

  • Matchers select what to change (.ignore, .type, .advice(matcher, ...)) — they run per discovered class and are evaluated on TypeDescriptions, without loading the target class.
  • Implementation defines the change itself — MethodDelegation (dispatch to an interceptor) or Advice (inline into the method body).
  • AgentBuilder defines when — at load time via the ClassFileTransformer, or retroactively via retransformation.
  • Class-loading strategy defines where the result lives — WRAPPER/INJECTION for generated classes, or the existing loader for transformed ones.

Decision guide

SituationReach forWhy
Instantiate traced variants yourself; DI/test seamSubclass + MethodDelegationFull reflection metadata; original untouched; simple
Same, but minimal per-call overhead / no delegate objectSubclass + AdviceInlined; no Callable/args-array allocation you don’t need
Instrument classes you do not construct (framework beans, third-party)premain agent + AdviceApplies at load time regardless of construction site
Instrument a class already loaded in a running JVMAttach + RETRANSFORMATIONOnly mechanism that reaches existing objects — mind the schema rule
Mutating arguments / replacing return values / invoking with changed inputsMethodDelegationAdvice reads; delegation rewrites
Production observability at scaleMicrometer, OpenTelemetry agentThis article’s tool is a teaching example — see below

What this is not yet

This is a working tracer, not an observability framework. Before attaching it to anything you care about:

  • Filter aggressively. isPublic() on a package prefix is a scalpel; a sink of per-method lines on a hot path is a firehose. Match on annotations (@Timed-style markers) or explicit class lists.
  • Measure the overhead. Every traced call pays for nanoTime twice, a ThreadLocal read, string concatenation, and printf — the I/O dwarfs the instrumentation. Benchmark with JMH under representative load before declaring it cheap, and batch records to a sink rather than printing per call.
  • Never trace arguments blindly. @AllArguments is where credentials, PII, and payloads live; this is exactly how secrets end up in log aggregators.
  • Decide your failure mode. Should a tracer exception kill the business call? suppress = Throwable.class says no — and then instrumentation failures are silent, which is its own incident.
  • Mind the correlation context. A ThreadLocal dies at the first thread hop; async and reactive paths need propagation machinery.

Troubleshooting

SymptomLikely cause
None of [...] allows for delegation from ...Return-type mismatch without @RuntimeType, or a delegate throws clause wider than the target’s
Trace lines absent; no errorType matcher too narrow, or the target class was loaded before the agent installed (premain fixes this; attach + retransformation if it can’t)
Failed to find Premain-ClassManifest entry missing or wrong FQN — check jar/maven-jar-plugin config
NoClassDefFoundError inside instrumented code at runtimeInlined advice references a class invisible to the target’s loader — bootstrap-loaded targets need ClassInjector bootstrap injection, not the classpath
class redefinition failed: attempted to add a method on attachTransformer added a member (auxiliary method, Nexus <clinit>) — keep the transformation body-only, e.g. InitializationStrategy.NoOp with pure Advice
Advice appears to run for hashCode/toStringnot(isDeclaredBy(Object.class)) missing from the method matcher
Trace shows the wrong class name ($ByteBuddy$...)@Origin("#t") on a subclass reports the generated type — expected; under transformation it reports the real class
Dynamic loading of agents will be disallowed warning on attachJDK warning for ByteBuddyAgent.install() — -XX:+EnableDynamicAgentLoading silences it on JDK 21; for production, attach via -javaagent or an external jcmd/attach mechanism rather than self-attach

What you built

One small TraceRecorder, three delivery mechanisms. The subclass proved the interception model — matchers select methods, generated overrides dispatch to an interceptor, self-calls through this are caught, and existing instances are unreachable. MethodDelegation exposed the binding machinery and its costs. Advice inlined the same behavior into the method body, which is what made the premain agent — and then retransformation of a loaded class — possible without adding a single member to the target.

The combination is the point: matchers scope, advice inlines, the agent installs, and the class-loading strategy decides which objects ever see the result. Everything else — filtering, sinks, propagation, overhead budgets — is production work on top of a mechanism you can now predict.


Further reading: the Byte Buddy tutorial and javadoc cover the full annotation surface (@This, @DefaultCall, @Morph, MethodCall), and the java.lang.instrument specification documents exactly what premain, agentmain, and retransformation guarantee.

JVM

Related Articles

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind