Legatium logs one structured adapter_* line per outbound HTTP exchange — named after the Roman legatus, the envoy a service sends to a foreign party, and the record of what came of it. Two auto-configured Spring Boot twins with identical fields and configuration: a RestClient/RestTemplate interceptor and a WebClient filter. No starter, no forced transitives.
- Emitted when the exchange is truly over. The event is emitted at response close on the blocking
stack and at the body's terminal signal on the reactive one, so status, headers, bodies and the
duration are final:
adapter_duration_msis response occupancy including the body read, not a bare round trip. A call without a response still yields exactly one line, with-> -. - An outcome that names who is responsible.
success,rejected(a 4xx),failure,timeoutandcancelledsay which side the disposition belongs to; the level carries severity separately, so the meaning of a line never depends on how loud it was logged.
- Two paradigm twins, identical fields. The
RestClient/RestTemplateinterceptor and theWebClientfilter emit the same fields under the same names with the same shapes, bound by the sameadapter-logging.*keys, and lockstep tests pin every literal: a field, a message format or a meter that drifts between the twins fails the build.
- Identity that joins the lines. The trace id is the request id; on a traceless call the module sends
an
X-Correlation-Idinstead, so the peer can quote it. A client line emitted while a request is served inherits the server line's identity from the MDC, and the reactive twin restores the caller's context around its emission, the blocking twin the caller's MDC for a response closed on another thread: the outbound line always carries the identity of the request that caused it.
- Header values masked by default. A logged header value is a stable keyed fingerprint unless it
is on an explicit plaintext allowlist; the same
masking-keyon both sides of the family makes a masked token read identically on the inbound and the outbound line. - Bodies teed as they flow. Bodies are never pre-read or replayed: they are teed as the
application reads them, bounded by
max-body-bytes, andon-failurelogs them only for the exchanges that went wrong.
- Fail-open, and the loss reports itself. A logging failure never reaches the caller and never
changes the call. It is swallowed, counted in
adapter.logging.failopenby stage, and the events counter is the ground truth to reconcile against the log index, so a lost line is visible through a channel that does not depend on the line. - Meters that are consumed, not exported. Six meter families are fed into the host's own registry,
pre-registered at zero so a
rate()alert sees the baseline before the first occurrence; rates, latencies and status distributions are left tohttp.client.requestson purpose. - The logger level is the volume control, at runtime. Because the level carries severity only, the
level of the
adapter-http-exchangelogger decides how much is logged without changing what a line means:INFOevery call,WARNfailures, timeouts, slow calls, cancellations and the four escalated rejections,ERRORonly calls that threw,OFFnothing. Level and outcome are resolved before the event is built, so a disabled level costs no assembly, no header selection, no body decoding, and the meters are recorded before the gate. Turn it up during an incident through the host's logging backend (Boot's loggers endpoint included) and down again, no restart, no redeploy; the module's own logger undereu.inqudium.legatiumreports atDEBUGhow it is wired and atTRACEwhere every property value came from.
-
Add the twin for the client the host calls out with. There is no BOM; the version is declared on the dependency (the current release is in the compatibility table below and on the Maven Central badge). No web application is required: a batch job or a message consumer that calls out is a client too.
RestClient/RestTemplate(blocking; needs Boot'sspring-boot-restclient, whichspring-boot-starter-restclientbrings):<dependency> <groupId>eu.inqudium</groupId> <artifactId>legatium-restclient-logging</artifactId> <version>1.2.0</version> </dependency>
WebClient(reactive, also from coroutines; needs Boot'sspring-boot-webclient, whichspring-boot-starter-webclientandspring-boot-starter-webfluxbring):<dependency> <groupId>eu.inqudium</groupId> <artifactId>legatium-webclient-logging</artifactId> <version>1.2.0</version> </dependency>
-
Build the client from Boot's injected builder. The auto-configuration attaches the interceptor or filter to every builder Boot hands out; a client from
RestClient.create(...), the staticRestClient.builder(), a bareRestTemplate()orWebClient.create(...)bypasses Boot's customizers and logs nothing.@Service class ThingsAdapter(builder: RestClient.Builder) { // or WebClient.Builder, RestTemplateBuilder private val client = builder.baseUrl("https://api.example.com").build() }
Every call is then one
INFOevent on theadapter-http-exchangelogger:Adapter http exchange POST https://api.example.com/things/42 -> 200 [adapter_request_id=4bf92f3577b34da6a3ce929d0e0e4736 traceId=4bf92f3577b34da6a3ce929d0e0e4736 spanId=00f067aa0ba902b7]A call made while a request is served inherits the server line's identity from the MDC. With Boot's structured logging (
logging.structured.format.console=ecs) the same event is one JSON document with theadapter_*fields as flat, typed top-level fields. -
Tune it, if the defaults are not yours. Every key lives under
adapter-logging.*and is the same for both twins; the configuration reference lists them all with their defaults. The usual first adjustments:adapter-logging: exclude-hosts: [pushgateway.monitoring.svc] # skip the peers that are infrastructure, not partners log-request-body: on-failure # bodies only for calls that went wrong log-response-body: on-failure masking-key: ${ADAPTER_MASKING_KEY} # key the header fingerprint; a secret, share it with Limesium logging: level: adapter-http-exchange: WARN # or INFO for every call; change it at runtime
The module READMEs carry the details: prerequisites, the automatic wiring and when to wire by hand, and what one exchange looks like as text and as JSON: RestClient, WebClient.
Legatium derives from legatus, the Roman envoy. A client call is exactly that: the service sends someone to a foreign party and records what came of it. Limesium guards the border from within; Legatium accompanies the envoy outward. The pair of names explains itself in a single sentence, sounds like an element, and is entirely unclaimed on GitHub.
The form follows the naming of chemical elements, like its sibling
Limesium — the project that logs the inbound
crossings at the service's own frontier. Together they cover both directions of a service's HTTP
traffic with the same design: one structured line per exchange, fail-open, identical across two
paradigm twins. Legatium's fields carry the adapter_ prefix and Limesium's the endpoint_ prefix,
so a log document may hold both — a client line emitted while a request is being served inherits
the server line's identity from the MDC — and no field ever means two things. Both are published
under the eu.inqudium group, the fictional periodic table of Inqudium.
Two paradigm twins with identical fields and identical configuration:
| Module | Client | Root package |
|---|---|---|
legatium-restclient-logging |
RestClient and RestTemplate (blocking, ClientHttpRequestInterceptor) |
eu.inqudium.legatium.restclient.logging |
legatium-webclient-logging |
WebClient (reactive, ExchangeFilterFunction) |
eu.inqudium.legatium.webclient.logging |
Both are auto-configured Spring Boot libraries — no starter, no forced logging transitives; the host
application brings the client and its engine (the JDK HttpClient, Apache, Reactor Netty, ...) and the
Logback binding. Neither needs a web application: a batch job or a message consumer that calls out is
a client too.
Adapter http exchange POST https://api.example.com/things/42 -> 200 [adapter_request_id=4bf92f3577b34da6a3ce929d0e0e4736 traceId=4bf92f3577b34da6a3ce929d0e0e4736 spanId=00f067aa0ba902b7]
plus the structured adapter_* key-values — outcome, duration until the response was fully read, method,
status, peer host, the client's name when the host set one, URI template, path, query, optional headers
and bodies — and the identity in the MDC. A client the host named reads by that name in place of the
target (Adapter http exchange POST things -> 200 [...]); the target stays in the MDC and the fields.
The trace ids come from the traceparent header the host's tracing propagation put on the request; on a
traceless call the module sends an X-Correlation-Id instead, so the peer can quote it. Outcomes:
success, rejected (a 4xx), failure, timeout, and on the reactive stack cancelled.
Documentation site: inqudium.github.io/legatium — guides, Elasticsearch mapping, generated test evidence, coverage reports, and the Dokka API references.
- Common guide — everything that is one contract for both twins, written once: prerequisites, dependency, overriding beans, the exchange line and the logging backend, index mapping, configuration, fields, MDC keys, meters, trace correlation, scope and fail-open guarantees, the shared code.
- RestClient guide — the long-form guide of the reference implementation: architecture, integration, configuration, metrics.
- WebClient guide — the twin's guide, including the deliberate stack differences.
- Configuration reference —
every
adapter-logging.*key with its default, contract-tested against both twins. - Elasticsearch mapping — the ready-made
component template for the
adapter_*fields. - Decision records — why the trace id is the request id, why the shared code is inlined, why the default id counts instead of rolling dice.
- Limesium — the sibling project for the inbound
side: one structured
endpoint_*line per request the service receives, on the loggerendpoint-http-exchange, built to the same design. Run both and a log document holds the server line and the client lines of the calls it made, joined by the shared request id - and because both mask header values with the same stable fingerprint (the samemasking-keyon both sides keeps it so), a masked token reads identically on the inbound and the outbound line.
Each Legatium release is built and tested against one Spring Boot line, one Kotlin line and one Java target; the table is the history of those lines, newest first. The Java column is the bytecode target the artifacts run on - the build itself needs JDK 24+.
| Legatium | Spring Boot | Kotlin | Java |
|---|---|---|---|
| 1.2.0 | 4.1.x | 2.4.x | 21 |
| 1.1.0 | 4.1.x | 2.4.x | 21 |
| 1.0.0 | 4.1.x | 2.4.x | 21 |
Pick the module for the client the host calls out with and follow the Usage section of its README — prerequisites, the dependency with the current version, how the interceptor or filter is wired automatically, when and how to wire it by hand, and what one logged exchange looks like as text and as JSON:
RestClient/RestTemplate(blocking):legatium-restclient-logging→ Usage — automatic wiring, manual wiring.WebClient(reactive, also from coroutines):legatium-webclient-logging→ Usage — automatic wiring, manual wiring.
An application may carry both jars — a servlet host using RestClient for most calls and WebClient
for a streaming one gets both logged, in one format.
mvn verify
Maven multi-module build (group eu.inqudium), Java 21, Kotlin, Spring Boot parent. The twins compile
against the shared legatium-common module through the reactor, so build from the root (or with -am).
The same source and version produce the same bytes on any machine: the root
POM sets project.build.outputTimestamp (bumped in every release commit), so
the jar, source and shade archivers write that instant as every zip entry's
timestamp, sort the entries and normalize their permissions, and the manifests
carry no build user, build JDK or Maven version (Build-Jdk-Spec is omitted on
purpose - CI builds on JDK 25, a maintainer on whatever 24+ is installed). This
holds for all published jars of both twins: the javadoc jar is rendered by
Dokka but packaged by the jar plugin, because Dokka's own javadocJar goal
writes the build time and JDK into the archive. Consequently the jars the
Release workflow attaches to the GitHub release (built on the tag by GitHub
Actions, attested with SLSA provenance) and the jars deployed to Maven Central
from a local checkout of the same tag are byte-identical. To check a build, run
mvn -DskipTests package twice, or once on the tag, and compare
sha256sum <twin>/target/<twin>-<version>.jar with the release asset.
Contributions are welcome — please read CONTRIBUTING.md first. The Code of Conduct applies to all project spaces, and security issues should be reported privately as described in SECURITY.md.
Licensed under the Apache License, Version 2.0.