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.
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:
[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-1042charged: order O-100 priced at 49.90[trace] fail in.o612.eng.tracedemo.service.OrderService.quote 49 µs cid=req-1043 error=IllegalStateExceptioncaller saw: unknown order O-999Everything 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.
| Approach | Affects existing instances? | Can touch already-loaded classes? | Structural freedom |
|---|---|---|---|
| Subclass | No — new instances only | N/A | Full — new class, any shape |
| Redefinition (pre-load) | All instances, once loaded | No — runs before loading | Full — but schema fixed at load |
| Retransformation (post-load) | Yes — class is replaced in place | Yes | Method 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.
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.
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.
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<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.
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(); } }}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:
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-1042charged: order O-100 priced at 49.90[trace] fail quote 58 µs cid=req-1043 error=IllegalStateExceptioncaller saw: unknown order O-999-- plain instance below --charged: order O-100 priced at 49.90caller saw: unknown order O-999Several things worth noticing in that output:
- The internal
charge → quotecall is traced. Becausechargeinvokesthis.quote(...), andthisis 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 sameIllegalStateExceptionit 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 aCallablerather than invoking eagerly is what lets the interceptor wrap the call intry/catch.@Origin Method method— thejava.lang.reflect.Methodof the instrumented method.@Origincan also injectStringtemplates ("#t.#m"producescom.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 ...:
@RuntimeTypeis required for a return-type mismatch. The interceptor returnsObject, butquotereturnsString. Without@RuntimeType, Byte Buddy requires the delegate’s return type to be assignable to the instrumented method’s return type —Objectis not assignable toString. The annotation tells the binder to insert acheckcastat the call site. (The cast is checked by the JVM, so a wrong return type fails withClassCastException, not silent corruption.)- Declared exceptions are checked. An early version of
TimingInterceptordeclaredthrows Throwable— and binding failed again, because the binder validates that every checked exception the delegate declares is compatible with the target method’sthrowsclause.quotedeclares none, so the delegate may declare none either. That is whyrethrowuses the generic sneaky-throw:<E extends Throwable> RuntimeException rethrow(Throwable t) throws Ecompiles to a plainathrow— the JVM does not verify checked exceptions at the bytecode level, so the original exception escapes with its exact type and stack trace. CatchingThrowableand rethrowing viathrow twould not compile, and wrapping it inRuntimeExceptionwould 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.
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.OnMethodEnterruns at method entry; its non-void return value can be passed to the exit advice via@Advice.Enter— this is howstarttravels 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.Thrownbinds that throwable (nullon normal return). WithoutonThrowable, 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:
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:
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/ThrowableThe 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.exitinsideOrderService.quoteis resolved byOrderService’s class loader — soTraceRecordermust 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.
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 widensin.o612.eng.tracedemo.service.toin.o612.eng., the tracer’s own classes come into scope, and an instrumentedTraceRecorder.exitcalled from inlined advice insideTraceRecorder.exitis 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 likenameStartsWith("com.acme.billing.")orisAnnotatedWith(RestController.class). ResistElementMatchers.any()— every discovered class pays the price of evaluating your transformer, and instrumenting JDK internals is a reliable way to produce confusingNoClassDefFoundErrors during startup.AgentBuilder.Transformer.ForAdviceapplies 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 (isMethodmatches only methods, not<init>/<clinit>) and theObjecttrio.
The demo Main is deliberately boring — plain construction, no Byte Buddy imports:
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:
mvn packagejava -javaagent:target/bytebuddy-tracer-1.0.0.jar \ -cp target/bytebuddy-tracer-1.0.0.jar \ in.o612.eng.tracedemo.Main[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-1042charged: order O-100 priced at 49.90[trace] fail in.o612.eng.tracedemo.service.OrderService.quote 49 µs cid=req-1043 error=IllegalStateExceptioncaller saw: unknown order O-999Same output as stage 1, but new OrderService() — unmodified — is traced. Two subtle differences worth noticing:
#tnow prints the real class name. Under subclassing,@Originresolved toOrderService$ByteBuddy$...; under transformation the instrumented class isOrderService, which is what you want in logs.- The tracer classes resolve through the application class loader.
-javaagentappends the agent jar to the system classpath, soin.o612.eng.tracer.*classes are loadable by the same loader that ownsOrderService, and the inlinedinvokestaticresolves. If you instrumented a class loaded by the bootstrap loader (ajava.*class, say), that loader cannot see the classpath — the inlined call would fail withNoClassDefFoundError. Real agents solve this by injecting helper classes into the bootstrap loader (ClassInjector.UsingInstrumentation, wrapped byAgentBuilder’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.
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 bytecodebefore: order O-100 priced at 49.90[trace] ok in.o612.eng.tracedemo.service.OrderService.quote 407 µsafter: order O-200 priced at 12.50The 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
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 onTypeDescriptions, without loading the target class. - Implementation defines the change itself —
MethodDelegation(dispatch to an interceptor) orAdvice(inline into the method body). AgentBuilderdefines when — at load time via theClassFileTransformer, or retroactively via retransformation.- Class-loading strategy defines where the result lives —
WRAPPER/INJECTIONfor generated classes, or the existing loader for transformed ones.
Decision guide
| Situation | Reach for | Why |
|---|---|---|
| Instantiate traced variants yourself; DI/test seam | Subclass + MethodDelegation | Full reflection metadata; original untouched; simple |
| Same, but minimal per-call overhead / no delegate object | Subclass + Advice | Inlined; no Callable/args-array allocation you don’t need |
| Instrument classes you do not construct (framework beans, third-party) | premain agent + Advice | Applies at load time regardless of construction site |
| Instrument a class already loaded in a running JVM | Attach + RETRANSFORMATION | Only mechanism that reaches existing objects — mind the schema rule |
| Mutating arguments / replacing return values / invoking with changed inputs | MethodDelegation | Advice reads; delegation rewrites |
| Production observability at scale | Micrometer, OpenTelemetry agent | This 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
nanoTimetwice, aThreadLocalread, string concatenation, andprintf— 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.
@AllArgumentsis 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.classsays no — and then instrumentation failures are silent, which is its own incident. - Mind the correlation context. A
ThreadLocaldies at the first thread hop; async and reactive paths need propagation machinery.
Troubleshooting
| Symptom | Likely 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 error | Type 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-Class | Manifest entry missing or wrong FQN — check jar/maven-jar-plugin config |
NoClassDefFoundError inside instrumented code at runtime | Inlined 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 attach | Transformer 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/toString | not(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 attach | JDK 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.