Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion logged-benchmarks/dependency-reduced-pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<parent>
<artifactId>logged-lib</artifactId>
<groupId>com.fayupable</groupId>
<version>1.2.2</version>
<version>1.3.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>logged-benchmarks</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion logged-benchmarks/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>com.fayupable</groupId>
<artifactId>logged-lib</artifactId>
<version>1.2.2</version>
<version>1.3.0</version>
</parent>

<artifactId>logged-benchmarks</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion logged-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>com.fayupable</groupId>
<artifactId>logged-lib</artifactId>
<version>1.2.2</version>
<version>1.3.0</version>
</parent>

<artifactId>logged-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,31 @@
* @return the sampling rate, defaulting to 1.0 (log every call)
*/
double sampleRate() default 1.0;

/**
* Whether the caller's IP address should be resolved and recorded
* alongside whatever else the caller-identity resolver produces,
* instead of only as a fallback used when no identity is available.
*
* <p>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.
*
* <p>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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,6 +65,7 @@ public record MethodInvocationEvent(
String exceptionType,
String rootCauseType,
String callerIdentity,
String callerIp,
String traceId,
int depth
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
}
}
2 changes: 1 addition & 1 deletion logged-spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>com.fayupable</groupId>
<artifactId>logged-lib</artifactId>
<version>1.2.2</version>
<version>1.3.0</version>
</parent>

<artifactId>logged-spring</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,12 @@ public Object logInvocation(ProceedingJoinPoint pjp, Logged logged) throws Throw
String className = names.className();
String methodName = names.methodName();
String callerIdentity = clientInfoPort.resolveCallerIdentity();
String callerIp = logged.includeIp() ? clientInfoPort.resolveCallerIp() : null;
long start = System.nanoTime();

FlowContextHolder.FlowScope flowScope = FlowContextHolder.enter();
Runnable restoreMdc = mdcEnabled
? MdcPropagation.push(flowScope.context(), className, methodName)
? MdcPropagation.push(flowScope.context(), className, methodName, callerIp)
: NO_OP_MDC_RESTORE;
try {
Object result;
Expand All @@ -197,18 +198,18 @@ public Object logInvocation(ProceedingJoinPoint pjp, Logged logged) throws Throw
String exceptionType = t.getClass().getSimpleName();
String rootCauseType = resolveRootCauseType(t);
long durationNanos = System.nanoTime() - start;
recordObservability(logged, className, methodName, callerIdentity, durationNanos,
recordObservability(logged, className, methodName, callerIdentity, callerIp, durationNanos,
false, exceptionType, rootCauseType, flowScope.context());
throw t;
}

if (result instanceof CompletableFuture<?> future) {
FlowContext capturedContext = flowScope.context();
return instrumentCompletableFuture(future, logged, className, methodName, callerIdentity, start, capturedContext);
return instrumentCompletableFuture(future, logged, className, methodName, callerIdentity, callerIp, start, capturedContext);
}

long durationNanos = System.nanoTime() - start;
recordObservability(logged, className, methodName, callerIdentity, durationNanos,
recordObservability(logged, className, methodName, callerIdentity, callerIp, durationNanos,
true, null, null, flowScope.context());
return result;
} finally {
Expand Down Expand Up @@ -245,6 +246,11 @@ public Object logInvocation(ProceedingJoinPoint pjp, Logged logged) throws Throw
* @param methodName the method name resolved for this invocation
* @param callerIdentity the caller identity resolved synchronously on
* the original calling thread
* @param callerIp the caller's IP address resolved synchronously
* on the original calling thread, for the same
* reason as {@code callerIdentity} above; or
* {@code null} if {@link Logged#includeIp()} is
* {@code false} for this invocation
* @param start the {@link System#nanoTime()} reading taken
* when this invocation began
* @param capturedContext the call-chain position this invocation was
Expand All @@ -255,7 +261,7 @@ public Object logInvocation(ProceedingJoinPoint pjp, Logged logged) throws Throw
*/
private CompletableFuture<?> instrumentCompletableFuture(CompletableFuture<?> future, Logged logged,
String className, String methodName, String callerIdentity,
long start, FlowContext capturedContext) {
String callerIp, long start, FlowContext capturedContext) {
return future.whenComplete((value, throwable) -> {
long durationNanos = System.nanoTime() - start;
boolean success = throwable == null;
Expand All @@ -268,7 +274,7 @@ private CompletableFuture<?> instrumentCompletableFuture(CompletableFuture<?> fu
rootCauseType = resolveRootCauseType(unwrapped);
}

recordObservability(logged, className, methodName, callerIdentity, durationNanos,
recordObservability(logged, className, methodName, callerIdentity, callerIp, durationNanos,
success, exceptionType, rootCauseType, capturedContext);
});
}
Expand Down Expand Up @@ -341,24 +347,24 @@ private String resolveRootCauseType(Throwable thrown) {
* #metricsRecorder} and, if {@link EmissionPolicy} selects it, through
* {@link #eventEmitter}.
*
* <p>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.
* <p>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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
}
Loading
Loading