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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

## [1.2.0] - 2026-08-04

### 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.

## [1.1.0] - 2026-08-03

### Added
Expand Down Expand Up @@ -48,6 +55,7 @@ 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.1.0...HEAD
[Unreleased]: https://github.com/fayupable/logged-lib/compare/v1.2.0...HEAD
[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
[1.0.0]: https://github.com/fayupable/logged-lib/releases/tag/v1.0.0
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ public class OrderService {
- [Call chain tracking](#call-chain-tracking)
- [Async, `@Async`, and virtual thread support](#async-async-and-virtual-thread-support)
- [Structured logging (MDC)](#structured-logging-mdc)
- [JSON event emission](#json-event-emission)
- [Cross-service tracing over Kafka and RabbitMQ](#cross-service-tracing-over-kafka-and-rabbitmq)
- [Cross-service tracing over HTTP](#cross-service-tracing-over-http)
- [Where `@Logged` belongs](#where-logged-belongs)
- [Testing with logged-test](#testing-with-logged-test)
- [Configuration](#configuration)
- [Modules](#modules)
- [Metrics](#metrics)
Expand Down Expand Up @@ -269,6 +271,29 @@ logged:

`FlowContextPropagatingExecutor` and `FlowContextTaskDecorator` (see [Async, `@Async`, and virtual thread support](#async-async-and-virtual-thread-support)) propagate the *entire* MDC context map across a thread hand-off, not only these four keys — so any MDC entries your own code has set (a request id from a web filter, for example) survive a thread boundary the same way this library's own do.

## JSON event emission

The default `Slf4jInvocationEventEmitter` writes a human-readable prose line, which is easy to read at a terminal but awkward for a structured logging backend (Loki, Elasticsearch, Datadog, and similar) that parses each log line as JSON: the whole line either fails to parse or gets indexed as one opaque string field, so you cannot filter or aggregate on individual fields such as `className` or `success`.

`JsonInvocationEventEmitter` is a drop-in alternative that writes the exact same `MethodInvocationEvent` fields as a single line of JSON instead:

```json
{"className":"OrderService","methodName":"placeOrder","timestamp":"2026-08-04T10:00:01.052Z","durationMs":187,"success":true,"exceptionType":null,"rootCauseType":null,"callerIdentity":"user:42","traceId":"fd6a56d153407322","depth":0}
```

Register it in place of the default:

```java
@Bean
InvocationEventEmitter invocationEventEmitter() {
return new JsonInvocationEventEmitter();
}
```

No JSON library is used or required — every field on `MethodInvocationEvent` is a primitive, a `String`, or an `Instant`, so a small hand-written writer is enough, and this stays true to the rest of this library: no dependency is added to pick this up. String fields are escaped for quotes, backslashes, and control characters before being written, so the emitted line is always valid JSON regardless of what a class or method name happens to contain.

Like the default emitter, successful calls are logged at `INFO` and failed calls at `WARN`, and the exception message is never included — only its simple class name, consistent with everything else this library records.

## Cross-service tracing over Kafka and RabbitMQ

Everything above — call chain tracking, MDC, async propagation — works within a single service. A call chain that crosses a message queue into a different service starts over with a brand-new `traceId` on the other side by default: nothing carries it across a Kafka or RabbitMQ message on its own.
Expand Down Expand Up @@ -392,6 +417,66 @@ or remove @Logged from this method until reactive support is added.

This is deliberate too, for a different reason than entities/repositories: a reactive type is lazy — nothing runs until something subscribes — so by the time `@Logged` could record anything, the pipeline has only been assembled, not executed. Recording at that point would silently report a near-zero duration and unconditional success no matter what the pipeline actually does once subscribed to, which is worse than not measuring it at all, since it looks like real data. Rejecting it at startup is preferred over recording something misleading. `@Logged` already supports `CompletableFuture` correctly (see [Async, `@Async`, and virtual thread support](#async-async-and-virtual-thread-support)); full reactive support (bridging `FlowContext` and MDC through Reactor's `Context`) is a substantially larger feature and not yet implemented.

## Testing with logged-test

Asserting that `@Logged` actually observed a call correctly has historically meant either capturing and parsing log output, or hand-rolling a throwaway `InvocationEventEmitter`/`MetricsRecorder`/`IClientInfoPort` in every project that depends on this library. `logged-test` ships ready-made, thread-safe in-memory implementations of all three output ports instead, so a test can assert directly on Java objects.

Add it as a test-scoped dependency:

```xml
<dependency>
<groupId>com.fayupable</groupId>
<artifactId>logged-test</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>
```

Register the ones you need as `@TestConfiguration` beans — `@ConditionalOnMissingBean` in `LoggedAutoConfiguration` picks them up in place of the real `Slf4jInvocationEventEmitter`/`MicrometerMetricsRecorder`/caller resolver automatically:

```java
@TestConfiguration
class LoggedTestConfig {

@Bean
InvocationEventEmitter invocationEventEmitter() {
return new InMemoryInvocationEventEmitter();
}

@Bean
MetricsRecorder metricsRecorder() {
return new InMemoryMetricsRecorder();
}

@Bean
IClientInfoPort clientInfoPort() {
return new InMemoryClientInfoPort("user:42");
}
}

@SpringBootTest
@Import(LoggedTestConfig.class)
class OrderServiceTest {

@Autowired InMemoryInvocationEventEmitter emitter;
@Autowired OrderService orderService;

@Test
void recordsSuccessfulOrderProcessing() {
orderService.processOrder(order);

MethodInvocationEvent event = emitter.lastEvent();
assertThat(event.methodName()).isEqualTo("processOrder");
assertThat(event.success()).isTrue();
assertThat(event.callerIdentity()).isEqualTo("user:42");
}
}
```

The same three classes work without Spring at all, against a `LoggedAspect` (or any other interceptor) constructed directly in a plain JUnit test — `logged-test` depends on nothing but `logged-core`.

Each class exposes `clear()` so a single instance can be reused across test methods without leaking state between them, and is backed by a `CopyOnWriteArrayList` so recording from a different thread — for example inside work wrapped by `FlowContextPropagatingExecutor` — never loses an event or corrupts the recorded list.

## Configuration

Every property below is optional and defaults to preserving the library's out-of-the-box behavior unmodified.
Expand All @@ -415,10 +500,12 @@ logged:
|---|---|---|
| `logged-core` | nothing | ✅ |
| `logged-spring` | `logged-core`, Spring Boot (all `provided`) | ✅ |
| `logged-test` | `logged-core` | ✅ |
| `logged-benchmarks` | `logged-core`, `logged-spring` | ❌ internal only |

- **`logged-core`** is framework-free: the `@Logged` annotation, the `MethodInvocationEvent`/`FlowContext` models, and the output ports (`InvocationEventEmitter`, `MetricsRecorder`, `IClientInfoPort`), each with a no-op default. It has zero dependencies and can be used standalone by any interception mechanism — not only Spring AOP.
- **`logged-spring`** is the Spring Boot adapter: the `@Around` aspect, an SLF4J-backed emitter, a Micrometer-backed metrics recorder, a Spring Security-backed caller resolver, the startup guard described above, full auto-configuration, the `FlowContextPropagatingExecutor`/`FlowContextTaskDecorator` pair and the `RequestAttributes`/`SecurityContext` propagating decorators described in [Async, `@Async`, and virtual thread support](#async-async-and-virtual-thread-support), the MDC propagation described in [Structured logging (MDC)](#structured-logging-mdc), the `KafkaTraceHeaderCarrier`/`RabbitTraceHeaderCarrier` pair described in [Cross-service tracing over Kafka and RabbitMQ](#cross-service-tracing-over-kafka-and-rabbitmq), and the HTTP integrations (`HttpTraceClientHttpRequestInterceptor`, `FeignTraceRequestInterceptor`, `HttpTraceExchangeFilterFunction`, `HttpTraceServletFilter`, and the underlying `HttpTraceHeaderCarrier`) described in [Cross-service tracing over HTTP](#cross-service-tracing-over-http). Every Spring/Micrometer/Security/Kafka/RabbitMQ/Feign/WebFlux dependency it declares is `provided` scope, so none of it is forced onto a consuming project's dependency tree — if your application doesn't already have Micrometer, Spring Security, Kafka, RabbitMQ, Feign, or WebFlux, the corresponding feature simply falls back to a no-op, or is simply never invoked.
- **`logged-test`** provides in-memory test doubles for all three output ports, described in [Testing with logged-test](#testing-with-logged-test). It depends only on `logged-core`, so it works whether or not `logged-spring` is on the classpath.
- **`logged-benchmarks`** never leaves this repository; see [Benchmarks](#benchmarks).

## Metrics
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.1.0</version>
<version>1.2.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.1.0</version>
<version>1.2.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.1.0</version>
<version>1.2.0</version>
</parent>

<artifactId>logged-core</artifactId>
Expand Down
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.1.0</version>
<version>1.2.0</version>
</parent>

<artifactId>logged-spring</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.fayupable.logged.spring.emitter;

import com.fayupable.logged.core.model.MethodInvocationEvent;
import com.fayupable.logged.core.port.InvocationEventEmitter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* {@link InvocationEventEmitter} that writes each event as a single line of
* structured JSON through SLF4J, instead of the human-readable line produced
* by {@link Slf4jInvocationEventEmitter}.
*
* <p>This exists for applications that ship their logs to a structured
* backend (Loki, Elasticsearch, Datadog, and similar) that parses each log
* line as JSON. Emitting {@link Slf4jInvocationEventEmitter}'s prose line
* into such a backend either fails to parse or gets indexed as one opaque
* string field, losing the ability to filter or aggregate on individual
* fields (for example querying every failed call for a given
* {@code className}). This emitter writes every {@link MethodInvocationEvent}
* field as its own JSON property instead, so the backend's own JSON parser
* indexes them directly.
*
* <p>No JSON library is used or required: every field on
* {@link MethodInvocationEvent} is a primitive, a {@code String}, or an
* {@link java.time.Instant}, so a small hand-written writer is enough and
* keeps this class dependency-free, consistent with the rest of this
* library. String fields are escaped for quotes, backslashes, and control
* characters before being written, since {@code className}, exception type
* names, and caller identity ultimately originate from application code or
* an authenticated principal, not arbitrary user input — but escaping them
* anyway costs nothing and guarantees the emitted line is always valid JSON.
*
* <p>As with {@link Slf4jInvocationEventEmitter}, successful calls are
* logged at {@code INFO} and failed calls at {@code WARN}, and the
* exception message is never included, only its simple class name.
*/
public class JsonInvocationEventEmitter implements InvocationEventEmitter {

private static final Logger log = LoggerFactory.getLogger(JsonInvocationEventEmitter.class);

@Override
public void emit(MethodInvocationEvent event) {
String json = toJson(event);

if (event.success()) {
log.info("{}", json);
} else {
log.warn("{}", json);
}
}

private static String toJson(MethodInvocationEvent event) {
StringBuilder json = new StringBuilder(160);
json.append('{');
appendString(json, "className", event.className());
json.append(',');
appendString(json, "methodName", event.methodName());
json.append(',');
appendString(json, "timestamp", event.timestamp().toString());
json.append(',');
appendNumber(json, "durationMs", event.durationNanos() / 1_000_000);
json.append(',');
appendBoolean(json, "success", event.success());
json.append(',');
appendString(json, "exceptionType", event.exceptionType());
json.append(',');
appendString(json, "rootCauseType", event.rootCauseType());
json.append(',');
appendString(json, "callerIdentity", event.callerIdentity());
json.append(',');
appendString(json, "traceId", event.traceId());
json.append(',');
appendNumber(json, "depth", event.depth());
json.append('}');
return json.toString();
}

private static void appendString(StringBuilder json, String name, String value) {
json.append('"').append(name).append('"').append(':');
if (value == null) {
json.append("null");
} else {
json.append('"').append(escape(value)).append('"');
}
}

private static void appendNumber(StringBuilder json, String name, long value) {
json.append('"').append(name).append('"').append(':').append(value);
}

private static void appendBoolean(StringBuilder json, String name, boolean value) {
json.append('"').append(name).append('"').append(':').append(value);
}

private static String escape(String value) {
StringBuilder escaped = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"' -> escaped.append("\\\"");
case '\\' -> escaped.append("\\\\");
case '\n' -> escaped.append("\\n");
case '\r' -> escaped.append("\\r");
case '\t' -> escaped.append("\\t");
default -> {
if (c < 0x20) {
escaped.append(String.format("\\u%04x", (int) c));
} else {
escaped.append(c);
}
}
}
}
return escaped.toString();
}
}
Loading
Loading