diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9460539..b91ee80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
+## [1.3.0] - 2026-08-14
+
+### Added
+
+- New `logged-test` module: in-memory, thread-safe test doubles for all three output ports — `InMemoryInvocationEventEmitter`, `InMemoryMetricsRecorder` (backed by a new `RecordedMetric` record), and `InMemoryClientInfoPort` — so a consuming project can assert directly on `@Logged` behavior in tests instead of parsing log output or hand-rolling its own stubs. Depends only on `logged-core`, so it works with or without `logged-spring` on the classpath.
+- New `JsonInvocationEventEmitter` (`logged-spring`): writes each `MethodInvocationEvent` as a single line of structured JSON through SLF4J instead of `Slf4jInvocationEventEmitter`'s human-readable line, for applications shipping logs to Loki/Elasticsearch/Datadog and similar backends that parse each line as JSON. Uses a small hand-written JSON writer with proper string escaping instead of adding a JSON library dependency.
+- `@Logged(includeIp = true)`: records the caller's IP address alongside whatever `callerIdentity` resolves to, instead of only as its fallback tier, for security-sensitive operations (login, password reset, TOTP verification, admin mutations) where the IP remains valuable for audit/rate-limiting purposes even when the call also resolves to an authenticated identity — especially on a failed attempt. Adds `MethodInvocationEvent#callerIp()`, `IClientInfoPort#resolveCallerIp()` (a `default` method returning `null`, so existing implementations remain source-compatible), real implementations in `SpringSecurityClientInfoAdapter`/`HttpRequestClientInfoAdapter`, a new `LoggedMdcKeys.CALLER_IP` MDC key, `callerIp` output in both `Slf4jInvocationEventEmitter` and `JsonInvocationEventEmitter`, and IP support in `logged-test`'s `InMemoryClientInfoPort`. Resolved synchronously alongside `callerIdentity`, so it is correctly captured for `CompletableFuture`-returning methods the same way `callerIdentity` already is.
+
## [1.2.2] - 2026-08-14
### Fixed
@@ -61,7 +69,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
- `logged-benchmarks`: internal JMH benchmarks measuring the aspect's overhead against a direct, uninstrumented call.
- Quality gates: Pitest mutation testing (`logged-core` 100%, `logged-spring` 98%) and Checkstyle (0 violations), both enforced via `mvn verify`.
-[Unreleased]: https://github.com/fayupable/logged-lib/compare/v1.2.2...HEAD
+[Unreleased]: https://github.com/fayupable/logged-lib/compare/v1.3.0...HEAD
+[1.3.0]: https://github.com/fayupable/logged-lib/compare/v1.2.2...v1.3.0
[1.2.2]: https://github.com/fayupable/logged-lib/compare/v1.2.0...v1.2.2
[1.2.0]: https://github.com/fayupable/logged-lib/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/fayupable/logged-lib/compare/v1.0.0...v1.1.0
diff --git a/README.md b/README.md
index a26bed2..37e2c13 100644
--- a/README.md
+++ b/README.md
@@ -536,6 +536,21 @@ The default `IClientInfoPort` (active whenever Spring Security and Spring Web ar
The client IP is read from `getRemoteAddr()`, **not** from `X-Forwarded-For`, by default. That header is controlled by the client and can be forged by anyone unless the application sits behind a proxy configured to strip and re-set it — a deployment detail this library cannot know on its own. If your application does sit behind such a proxy, set `logged.client-info.trust-forwarded-headers=true` explicitly, mirroring how Spring Security itself requires trusted proxies to be declared rather than assumed.
+### Recording the caller's IP alongside identity
+
+The resolution chain above is exclusive: once an authenticated principal resolves, the IP is discarded. For most `@Logged` methods that's the right tradeoff. For a small set of security-sensitive operations — login, password reset, TOTP verification, admin mutations — the IP remains valuable for audit and rate-limiting purposes even when the call also resolves to an authenticated identity, and especially on a *failed* attempt, where the caller's identity may be unverified or entirely absent:
+
+```java
+@Logged(includeIp = true)
+public LoginResponse login(String username, String password) {
+ // ...
+}
+```
+
+Setting `includeIp = true` records the caller's IP unconditionally, in `MethodInvocationEvent#callerIp()` and, if MDC is enabled, under `LoggedMdcKeys.CALLER_IP` (`logged.callerIp`) — as a field alongside `callerIdentity`, not in place of it, and independent of whatever the identity tier resolves to. This is per-annotation, matching `slowThresholdMs`/`sampleRate`, rather than a global flag: IP is personal data under most privacy frameworks, so it is only recorded on the methods that explicitly opt in, not on every `@Logged` call across the application.
+
+`callerIp` is resolved synchronously on the calling thread, at the same point `callerIdentity` is — including for a method returning `CompletableFuture`, where it is captured before the async work begins, for the same reason described in [Async, `@Async`, and virtual thread support](#async-async-and-virtual-thread-support). It is `null` whenever it cannot be resolved (no active HTTP request, or a custom `IClientInfoPort` that does not implement `resolveCallerIp()`), never a placeholder string like `"unknown"` — consistent with `exceptionType`/`rootCauseType`, this library's other nullable structured fields.
+
## Benchmarks
`LoggedAspect`'s overhead is measured with [JMH](https://openjdk.org/projects/code-tools/jmh/), comparing a direct method call against the same call made through a `@Logged`-intercepted proxy, with every port wired to its no-op implementation. This isolates the cost of proxy dispatch, `FlowContext` `ThreadLocal` management, and the emission/metrics decision path, from the cost of any actual logging or metrics backend I/O.
diff --git a/logged-benchmarks/dependency-reduced-pom.xml b/logged-benchmarks/dependency-reduced-pom.xml
index 1b9f8b0..ef45b47 100644
--- a/logged-benchmarks/dependency-reduced-pom.xml
+++ b/logged-benchmarks/dependency-reduced-pom.xml
@@ -3,7 +3,7 @@
By default, an interceptor's caller-identity resolution is an + * either/or chain: an authenticated principal, if one resolves, takes + * priority over the caller's IP, which is then discarded. For most + * {@code @Logged} methods that is the right tradeoff — knowing who + * called is more useful than knowing where from, and recording both + * unconditionally would add IP address (personal data under most + * privacy frameworks) to every log line for no benefit. + * + *
For a small set of security-sensitive operations — login, password + * reset, TOTP verification, admin mutations — the IP remains valuable + * for audit and rate-limiting purposes even when the call also resolves + * to an authenticated identity, and especially on a failed attempt, + * where the caller's identity may be unverified or entirely absent. + * Setting this to {@code true} on exactly those methods records the IP + * unconditionally, without changing IP resolution for every other + * {@code @Logged} method in the application. + * + * @return whether to record the caller's IP address in addition to + * caller identity, defaulting to {@code false} + */ + boolean includeIp() default false; } \ No newline at end of file diff --git a/logged-core/src/main/java/com/fayupable/logged/core/model/MethodInvocationEvent.java b/logged-core/src/main/java/com/fayupable/logged/core/model/MethodInvocationEvent.java index ac9b109..4300582 100644 --- a/logged-core/src/main/java/com/fayupable/logged/core/model/MethodInvocationEvent.java +++ b/logged-core/src/main/java/com/fayupable/logged/core/model/MethodInvocationEvent.java @@ -41,6 +41,13 @@ * call (for example an authenticated user id or a * client IP address), resolved by an adapter-specific * implementation + * @param callerIp the caller's IP address, resolved independently of + * {@code callerIdentity} rather than only as its + * fallback tier, or {@code null} if not resolved. + * Only populated when {@link com.fayupable.logged.core.annotation.Logged#includeIp()} + * is {@code true} for the invoked method, or when + * the caller-identity resolver in use does not + * support resolving it at all * @param traceId the {@link FlowContext#traceId()} shared by every * call in the same chain of nested {@code @Logged} * invocations, allowing log output to be @@ -58,6 +65,7 @@ public record MethodInvocationEvent( String exceptionType, String rootCauseType, String callerIdentity, + String callerIp, String traceId, int depth ) { diff --git a/logged-core/src/main/java/com/fayupable/logged/core/port/IClientInfoPort.java b/logged-core/src/main/java/com/fayupable/logged/core/port/IClientInfoPort.java index 744bd5a..3b27468 100644 --- a/logged-core/src/main/java/com/fayupable/logged/core/port/IClientInfoPort.java +++ b/logged-core/src/main/java/com/fayupable/logged/core/port/IClientInfoPort.java @@ -29,4 +29,35 @@ public interface IClientInfoPort { * resolved */ String resolveCallerIdentity(); + + /** + * Resolves the IP address of the caller that triggered the current + * invocation, independent of whatever {@link #resolveCallerIdentity()} + * itself resolves to. + * + *
Unlike {@link #resolveCallerIdentity()}, which resolves a single + * identity from an either/or chain of tiers (authenticated principal, + * then IP, then a placeholder), this method exists so that a caller's IP + * can be recorded alongside an authenticated identity rather than only + * as a fallback used when no identity is available — valuable for + * security-sensitive operations (login, password reset, admin actions) + * where the IP remains useful for audit and rate-limiting purposes even + * when the call also resolves to an authenticated user. + * + *
This is a default method, not an abstract one, so that adding it
+ * does not break existing implementations of this interface compiled
+ * against an earlier version of this library. The default returns
+ * {@code null}, meaning "this adapter does not support resolving an IP
+ * independent of caller identity" — consistent with this library's
+ * convention of using {@code null} for "not available" on structured
+ * fields, rather than a placeholder string.
+ *
+ * @return the caller's IP address, or {@code null} if it cannot be
+ * resolved (for example, no HTTP request is available on the
+ * current thread, or this adapter does not implement IP
+ * resolution)
+ */
+ default String resolveCallerIp() {
+ return null;
+ }
}
\ No newline at end of file
diff --git a/logged-core/src/test/java/com/fayupable/logged/core/port/NoOpAdaptersTest.java b/logged-core/src/test/java/com/fayupable/logged/core/port/NoOpAdaptersTest.java
index a5ab734..5b73d73 100644
--- a/logged-core/src/test/java/com/fayupable/logged/core/port/NoOpAdaptersTest.java
+++ b/logged-core/src/test/java/com/fayupable/logged/core/port/NoOpAdaptersTest.java
@@ -17,7 +17,7 @@ class NoOpAdaptersTest {
void noOpInvocationEventEmitterDoesNotThrow() {
InvocationEventEmitter emitter = new NoOpInvocationEventEmitter();
MethodInvocationEvent event = new MethodInvocationEvent(
- "SomeClass", "someMethod", Instant.now(), 1_000_000L, true, null, null, "unknown", "trace-1", 0
+ "SomeClass", "someMethod", Instant.now(), 1_000_000L, true, null, null, "unknown", null, "trace-1", 0
);
assertThatCode(() -> emitter.emit(event)).doesNotThrowAnyException();
@@ -39,4 +39,20 @@ void noOpClientInfoPortReportsUnknownCaller() {
assertThat(clientInfoPort.resolveCallerIdentity()).isEqualTo("unknown");
}
+
+ @Test
+ @DisplayName("NoOpClientInfoPort inherits the default resolveCallerIp, reporting null")
+ void noOpClientInfoPortReportsNullIp() {
+ IClientInfoPort clientInfoPort = new NoOpClientInfoPort();
+
+ assertThat(clientInfoPort.resolveCallerIp()).isNull();
+ }
+
+ @Test
+ @DisplayName("IClientInfoPort's default resolveCallerIp returns null for any implementation that does not override it")
+ void interfaceDefaultResolveCallerIpReturnsNull() {
+ IClientInfoPort minimalImplementation = () -> "user:42";
+
+ assertThat(minimalImplementation.resolveCallerIp()).isNull();
+ }
}
\ No newline at end of file
diff --git a/logged-spring/pom.xml b/logged-spring/pom.xml
index 9a1527b..6c8c51b 100644
--- a/logged-spring/pom.xml
+++ b/logged-spring/pom.xml
@@ -7,7 +7,7 @@
Takes an already-resolved {@code callerIdentity} and {@link - * FlowContext} rather than resolving them itself, so this method works - * identically whether it is called synchronously, right after {@link - * ProceedingJoinPoint#proceed()} returns, or later from a {@link - * CompletableFuture} completion callback running on a different thread: - * neither value depends on {@link ThreadLocal} state at the point this - * method runs. + *
Takes an already-resolved {@code callerIdentity}, {@code callerIp}, + * and {@link FlowContext} rather than resolving them itself, so this + * method works identically whether it is called synchronously, right + * after {@link ProceedingJoinPoint#proceed()} returns, or later from a + * {@link CompletableFuture} completion callback running on a different + * thread: none of these values depend on {@link ThreadLocal} state at + * the point this method runs. */ private void recordObservability(Logged logged, String className, String methodName, String callerIdentity, - long durationNanos, boolean success, String exceptionType, String rootCauseType, - FlowContext context) { + String callerIp, long durationNanos, boolean success, String exceptionType, + String rootCauseType, FlowContext context) { try { metricsRecorder.record(className, methodName, durationNanos, success, exceptionType); if (EmissionPolicy.shouldEmit(logged, durationNanos, success)) { eventEmitter.emit(new MethodInvocationEvent( className, methodName, Instant.now(), durationNanos, success, exceptionType, rootCauseType, - callerIdentity, context.traceId(), context.depth() + callerIdentity, callerIp, context.traceId(), context.depth() )); } } catch (RuntimeException e) { diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/LoggedMdcKeys.java b/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/LoggedMdcKeys.java index edfd07d..3a8ea33 100644 --- a/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/LoggedMdcKeys.java +++ b/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/LoggedMdcKeys.java @@ -48,6 +48,14 @@ public final class LoggedMdcKeys { */ public static final String METHOD_NAME = "logged.methodName"; + /** + * The caller's IP address, the same value reported as + * {@link com.fayupable.logged.core.model.MethodInvocationEvent#callerIp()}. + * Only written while executing a method annotated with + * {@code @Logged(includeIp = true)}; absent for every other invocation. + */ + public static final String CALLER_IP = "logged.callerIp"; + private LoggedMdcKeys() { } } diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/MdcPropagation.java b/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/MdcPropagation.java index 3f9bff2..da046cf 100644 --- a/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/MdcPropagation.java +++ b/logged-spring/src/main/java/com/fayupable/logged/spring/aspect/MdcPropagation.java @@ -25,34 +25,45 @@ private MdcPropagation() { } /** - * Writes {@code context}, {@code className}, and {@code methodName} into - * the current thread's MDC under {@link LoggedMdcKeys}, and returns a - * {@link Runnable} that restores whatever value each key held before - * this call, once run. + * Writes {@code context}, {@code className}, {@code methodName}, and, + * when present, {@code callerIp} into the current thread's MDC under + * {@link LoggedMdcKeys}, and returns a {@link Runnable} that restores + * whatever value each key held before this call, once run. * * @param context the call-chain position of the invocation currently * starting * @param className the class name resolved for this invocation * @param methodName the method name resolved for this invocation + * @param callerIp the caller's IP address resolved for this + * invocation, or {@code null} if + * {@code @Logged(includeIp = true)} was not set on the + * invoked method; when {@code null}, {@link LoggedMdcKeys#CALLER_IP} + * is left untouched rather than written as an empty + * or missing value * @return a {@link Runnable} that undoes this write; must be run once * the invocation ends, typically in a {@code finally} block */ - static Runnable push(FlowContext context, String className, String methodName) { + static Runnable push(FlowContext context, String className, String methodName, String callerIp) { String previousTraceId = MDC.get(LoggedMdcKeys.TRACE_ID); String previousDepth = MDC.get(LoggedMdcKeys.DEPTH); String previousClassName = MDC.get(LoggedMdcKeys.CLASS_NAME); String previousMethodName = MDC.get(LoggedMdcKeys.METHOD_NAME); + String previousCallerIp = MDC.get(LoggedMdcKeys.CALLER_IP); MDC.put(LoggedMdcKeys.TRACE_ID, context.traceId()); MDC.put(LoggedMdcKeys.DEPTH, String.valueOf(context.depth())); MDC.put(LoggedMdcKeys.CLASS_NAME, className); MDC.put(LoggedMdcKeys.METHOD_NAME, methodName); + if (callerIp != null) { + MDC.put(LoggedMdcKeys.CALLER_IP, callerIp); + } return () -> { restore(LoggedMdcKeys.TRACE_ID, previousTraceId); restore(LoggedMdcKeys.DEPTH, previousDepth); restore(LoggedMdcKeys.CLASS_NAME, previousClassName); restore(LoggedMdcKeys.METHOD_NAME, previousMethodName); + restore(LoggedMdcKeys.CALLER_IP, previousCallerIp); }; } diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/JsonInvocationEventEmitter.java b/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/JsonInvocationEventEmitter.java index 6241390..64b3da0 100644 --- a/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/JsonInvocationEventEmitter.java +++ b/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/JsonInvocationEventEmitter.java @@ -68,6 +68,8 @@ private static String toJson(MethodInvocationEvent event) { json.append(','); appendString(json, "callerIdentity", event.callerIdentity()); json.append(','); + appendString(json, "callerIp", event.callerIp()); + json.append(','); appendString(json, "traceId", event.traceId()); json.append(','); appendNumber(json, "depth", event.depth()); diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/Slf4jInvocationEventEmitter.java b/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/Slf4jInvocationEventEmitter.java index 6f24f00..0b3f26d 100644 --- a/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/Slf4jInvocationEventEmitter.java +++ b/logged-spring/src/main/java/com/fayupable/logged/spring/emitter/Slf4jInvocationEventEmitter.java @@ -26,6 +26,13 @@ * that is, when the thrown exception actually wraps a different underlying * cause. When the two are equal, showing the root cause a second time would * add nothing, so it is omitted to keep the common case concise. + * + *
{@link MethodInvocationEvent#callerIp()} is only appended when present,
+ * that is, only for the security-sensitive methods explicitly annotated
+ * with {@code @Logged(includeIp = true)}. It is deliberately shown alongside
+ * {@link MethodInvocationEvent#callerIdentity()} rather than only in its
+ * place, since the two answer different questions ("who" versus "from
+ * where") that both matter for the operations this attribute is meant for.
*/
public class Slf4jInvocationEventEmitter implements InvocationEventEmitter {
@@ -34,19 +41,20 @@ public class Slf4jInvocationEventEmitter implements InvocationEventEmitter {
@Override
public void emit(MethodInvocationEvent event) {
long durationMs = event.durationNanos() / 1_000_000;
+ String ipSuffix = event.callerIp() != null ? " ip=" + event.callerIp() : "";
String chainSuffix = event.depth() > 0
? " (trace=" + event.traceId() + ", depth=" + event.depth() + ")"
: "";
if (event.success()) {
- log.info("[{}] {}.{} completed in {}ms{}",
- event.callerIdentity(), event.className(), event.methodName(), durationMs, chainSuffix);
+ log.info("[{}]{} {}.{} completed in {}ms{}",
+ event.callerIdentity(), ipSuffix, event.className(), event.methodName(), durationMs, chainSuffix);
} else {
boolean rootCauseDiffers = event.rootCauseType() != null && !event.rootCauseType().equals(event.exceptionType());
String causeSuffix = rootCauseDiffers ? " (caused by " + event.rootCauseType() + ")" : "";
- log.warn("[{}] {}.{} failed after {}ms - {}{}{}",
- event.callerIdentity(), event.className(), event.methodName(), durationMs,
+ log.warn("[{}]{} {}.{} failed after {}ms - {}{}{}",
+ event.callerIdentity(), ipSuffix, event.className(), event.methodName(), durationMs,
event.exceptionType(), causeSuffix, chainSuffix);
}
}
diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/security/HttpRequestClientInfoAdapter.java b/logged-spring/src/main/java/com/fayupable/logged/spring/security/HttpRequestClientInfoAdapter.java
index 5519577..9208e94 100644
--- a/logged-spring/src/main/java/com/fayupable/logged/spring/security/HttpRequestClientInfoAdapter.java
+++ b/logged-spring/src/main/java/com/fayupable/logged/spring/security/HttpRequestClientInfoAdapter.java
@@ -36,4 +36,9 @@ public String resolveCallerIdentity() {
String clientIp = RequestClientIpResolver.resolveClientIp(trustForwardedHeaders);
return clientIp != null ? clientIp : UNKNOWN_CALLER;
}
+
+ @Override
+ public String resolveCallerIp() {
+ return RequestClientIpResolver.resolveIp(trustForwardedHeaders);
+ }
}
diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/security/RequestClientIpResolver.java b/logged-spring/src/main/java/com/fayupable/logged/spring/security/RequestClientIpResolver.java
index 594fab8..175a03c 100644
--- a/logged-spring/src/main/java/com/fayupable/logged/spring/security/RequestClientIpResolver.java
+++ b/logged-spring/src/main/java/com/fayupable/logged/spring/security/RequestClientIpResolver.java
@@ -30,7 +30,9 @@ private RequestClientIpResolver() {
}
/**
- * Resolves the current request's client IP address.
+ * Resolves the current request's client IP address, prefixed for use as
+ * a {@link com.fayupable.logged.core.port.IClientInfoPort#resolveCallerIdentity()}
+ * fallback tier.
*
* @param trustForwardedHeaders whether the {@code X-Forwarded-For}
* header may be trusted over
@@ -40,6 +42,23 @@ private RequestClientIpResolver() {
* thread (for example, a scheduled job)
*/
static String resolveClientIp(boolean trustForwardedHeaders) {
+ String ip = resolveIp(trustForwardedHeaders);
+ return ip != null ? "ip:" + ip : null;
+ }
+
+ /**
+ * Resolves the current request's client IP address, unprefixed, for use
+ * as a {@link com.fayupable.logged.core.port.IClientInfoPort#resolveCallerIp()}
+ * dedicated field rather than as part of the caller-identity string.
+ *
+ * @param trustForwardedHeaders whether the {@code X-Forwarded-For}
+ * header may be trusted over
+ * {@link HttpServletRequest#getRemoteAddr()}
+ * @return the caller's raw IP address, or {@code null} if there is no
+ * active HTTP request on this thread (for example, a scheduled
+ * job)
+ */
+ static String resolveIp(boolean trustForwardedHeaders) {
HttpServletRequest request = currentRequest();
if (request == null) {
return null;
@@ -48,11 +67,11 @@ static String resolveClientIp(boolean trustForwardedHeaders) {
if (trustForwardedHeaders) {
String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER);
if (forwardedFor != null && !forwardedFor.isBlank()) {
- return "ip:" + forwardedFor.split(",")[0].trim();
+ return forwardedFor.split(",")[0].trim();
}
}
- return "ip:" + request.getRemoteAddr();
+ return request.getRemoteAddr();
}
private static HttpServletRequest currentRequest() {
diff --git a/logged-spring/src/main/java/com/fayupable/logged/spring/security/SpringSecurityClientInfoAdapter.java b/logged-spring/src/main/java/com/fayupable/logged/spring/security/SpringSecurityClientInfoAdapter.java
index 7da81c3..8826615 100644
--- a/logged-spring/src/main/java/com/fayupable/logged/spring/security/SpringSecurityClientInfoAdapter.java
+++ b/logged-spring/src/main/java/com/fayupable/logged/spring/security/SpringSecurityClientInfoAdapter.java
@@ -56,4 +56,16 @@ private String resolveAuthenticatedUser() {
private boolean isAnonymous(Authentication authentication) {
return ANONYMOUS_PRINCIPAL.equals(authentication.getPrincipal());
}
+
+ /**
+ * Resolves the caller's IP address independent of {@link #resolveCallerIdentity()}'s
+ * outcome, unlike that method's authenticated-principal-first fallback
+ * chain. This has no dependency on {@link SecurityContextHolder} state,
+ * so it resolves the same way whether or not the current invocation
+ * also resolves to an authenticated user.
+ */
+ @Override
+ public String resolveCallerIp() {
+ return RequestClientIpResolver.resolveIp(trustForwardedHeaders);
+ }
}
diff --git a/logged-spring/src/test/java/com/fayupable/logged/spring/aspect/LoggedAspectTest.java b/logged-spring/src/test/java/com/fayupable/logged/spring/aspect/LoggedAspectTest.java
index 7cdb6df..fbc6b84 100644
--- a/logged-spring/src/test/java/com/fayupable/logged/spring/aspect/LoggedAspectTest.java
+++ b/logged-spring/src/test/java/com/fayupable/logged/spring/aspect/LoggedAspectTest.java
@@ -49,8 +49,12 @@ void tearDown() {
}
private