Why does @Timed silently record nothing when an annotated method is called from another method in the same bean?
answer
- proxy-based AOP: only proxied calls advised
- this.method() = raw target, bypasses proxy
- same root cause as @Transactional self-call
- public + non-final; CGLIB vs JDK proxy
- fix: extract bean / self-inject / AopContext / AspectJ weaving
basics
~10 s@Timed relies on a Spring AOP proxy that wraps the bean. Self-invocation (this.method()) calls the method directly on the target object, bypassing the proxy, so the TimedAspect never runs and no metric is recorded.
solid answer
~50 s@Timed/@Counted work through Spring AOP, which wraps the bean in a proxy (JDK dynamic proxy or CGLIB). Interception only happens when a call passes **through** that proxy — i.e. an external caller invokes the injected bean reference. When a method inside the same bean calls another annotated method via `this.method()`, the call goes straight to the target instance and never touches the proxy, so `TimedAspect`/`CountedAspect` never fires — silently, no metric, no error. This is the general proxy self-invocation limitation, same reason @Transactional and @Cacheable self-calls fail. Fixes: move the annotated method to a separate bean and inject it; call through a self-injected proxy reference; use `AopContext.currentProxy()` (needs `exposeProxy = true`); or switch to AspectJ load-time/compile-time weaving, which instruments the actual bytecode and isn't proxy-limited. Also note @Timed requires the method be reachable on the proxy (public for CGLIB by default).
code
java · 17 lines@Service
public class ReportService {
// Self-injection so the internal call crosses the proxy
@Autowired
private ObjectProvider<ReportService> self;
public void run() {
self.getObject().generate(); // goes through proxy -> @Timed fires
// this.generate(); // BUG: bypasses proxy, no metric
}
@Timed("report.generate")
public void generate() {
// ...
}
}go deeper
Know that internal calls can skip the annotation's effect.
Explain that a proxy does interception and self-calls bypass it.
Detail JDK vs CGLIB proxying, visibility/final constraints, and multiple fixes; link to @Transactional.
Advise when to accept imperative instrumentation vs adopt AspectJ weaving fleet-wide, weighing complexity and consistency.
## The proxy model behind the annotations Spring AOP is **proxy-based**. When a bean has advised methods (matched by `TimedAspect`/`CountedAspect`), Spring wraps it in a proxy: - **JDK dynamic proxy** if the bean implements interfaces (and `proxyTargetClass=false`), or - **CGLIB subclass proxy** otherwise (the default in Spring Boot). Callers who **inject the bean** get the **proxy**, not the raw object. The proxy runs the aspect's `@Around` advice (start timer/count → invoke target → record) and then delegates to the real method. ## Why self-invocation bypasses it Inside the target object, `this` is the **raw instance**, not the proxy. So: ```java @Service class ReportService { public void run() { generate(); // this.generate() -> raw call, NO proxy } @Timed("report.generate") public void generate() { /* ... */ } } ``` `run()` calls `generate()` directly on the target. The proxy is never in the call path, so `TimedAspect` never executes and **no metric is recorded** — and, as with all AOP, there is no warning. This is identical to the well-known `@Transactional`/`@Cacheable`/`@Async` self-invocation trap. ## Other proxy constraints that cause silent no-ops - **Visibility/final:** with CGLIB proxies, `final` classes/methods and `private` methods can't be overridden, so they can't be advised. Interface (JDK) proxies only advise interface (public) methods. Practically, keep annotated methods **public and non-final**. - **Non-bean objects:** annotating a `new`-ed object or a static method does nothing — there's no Spring proxy. ## Fixes (choose per situation) 1. **Extract to another bean** (cleanest): move `generate()` into a separate `@Service` and inject it, so the external call crosses a proxy boundary. 2. **Self-injection**: inject the bean into itself (`@Autowired ReportService self;` or via an `ObjectProvider`) and call `self.generate()`; `self` is the proxy. 3. **AopContext.currentProxy()**: enable `@EnableAspectJAutoProxy(exposeProxy = true)` and call `((ReportService) AopContext.currentProxy()).generate()`. Works but couples code to Spring AOP. 4. **Switch to AspectJ weaving** (load-time or compile-time): instruments the real bytecode, so even self-invocations are advised. Heavier setup but removes the proxy limitation entirely. 5. **Fall back to imperative instrumentation**: inject `MeterRegistry`, build the `Timer`, and record inline — no AOP, no proxy dependency. Often the pragmatic choice for internal helper methods. ## Interview framing The key insight the interviewer wants: **the annotation isn't magic on the method; it's advice on the proxy, and only proxied calls are advised.** Recognizing that this shares a root cause with @Transactional self-invocation signals real understanding of Spring's AOP model.
- Name two other Spring annotations that fail under the exact same self-invocation condition.@Transactional and @Cacheable (also @Async, @Retryable, @PreAuthorize). All are proxy-based AOP, so an internal this.method() call bypasses the proxy and the advice never runs.
- How would AspectJ load-time weaving change this behavior?AspectJ weaves the aspect directly into the target class bytecode rather than relying on a wrapping proxy, so even self-invocations are intercepted. It removes the proxy limitation but adds weaving setup (agent/compile-time) complexity.
saying these in an interview costs you the question
- Claiming @Timed works on every method call regardless of how it's invoked.
- Suggesting making the method private 'to be safe' — private methods can't be advised by Spring AOP at all.
- Not connecting the failure to the general proxy/self-invocation problem (@Transactional etc.).