legatium-restclient-logging — Guide¶
One structured adapter_* log line per outbound HTTP exchange made through Spring's RestClient or
RestTemplate, with the exchange identity in the MDC while the wire call runs. This module is the
reference implementation of the adapter-logging family; its WebClient twin
legatium-webclient-logging shares the message format, the
field family, the adapter-logging.* configuration and the meters. The inbound counterpart of the whole
family is the sibling project Limesium.
This guide is the long-form companion to the module README. It explains what the module
does, how it is built, how it is wired into a foreign application, and which behaviours are specific to
the blocking client stack. Everything that is one contract for both twins — prerequisites, the
dependency, the beans, the exchange line and the logging backend, the configuration, the fields, the
meters, the fail-open promise and the shared code — is written once, in the
Common guide. Everything here is derived from the code under
src/main/kotlin/eu/inqudium/legatium/restclient/logging/; when the two disagree, the code wins.
Table of contents¶
- Introduction
- What the module does
- The WebClient twin
- Architecture
- Component overview
- Auto-configuration and registration
- Lifecycle of one exchange
- Emission point: response close
- The body tee
- MDC coverage
- Fail-open contract
- Using it in a foreign project
- Automatic wiring
- Manual wiring
- Interceptor order and other interceptors
- Verifying the integration
- Special characteristics
- Differences to the WebClient twin
- Duration is response occupancy
- A response that is never closed
- Failures on the response the client was handed
- Timeouts and how they are recognised
- RestTemplate has no URI template
- Retries yield one line per attempt
- Appendix
- File map
- Related documents
1. Introduction¶
1.1 What the module does¶
legatium-restclient-logging is a Spring Boot auto-configured ClientHttpRequestInterceptor, attached
through Boot's RestClientCustomizer and RestTemplateCustomizer to every client the host builds through
Boot. For every outbound HTTP exchange it:
- resolves the exchange identity per ADR-0002: a conformant
traceparenton the outgoing request — put there by the host's tracing propagation — makes its trace id the request id and leaves the wire untouched; only a traceless call adopts a correlation header already on the request, or generates one and sends it, so the peer can quote it; - puts
adapter_request_id,adapter_methodandadapter_routeinto the MDC for the wire call, as an additive overlay beside whatever the thread already carries (an inbound request'sendpoint_*keys from Limesium, a tracing bridge's keys); - optionally logs an arrival line the moment the request is sent;
- measures the exchange duration with an injectable monotonic time source — until the response is closed;
- captures the request body the client hands it (bounded) and optionally tees the response body as the application reads it (bounded, never buffered or replayed);
- optionally records the selected request/response headers, with stable masking of sensitive values;
- parses the outgoing W3C
traceparentheader (traceId/spanId) so the event stays joinable with its trace; - emits exactly one structured completion event at response close — after the client's converters read the body, so status, headers, body and duration are final;
- feeds six Micrometer meters that observe the logging itself.
It does all of this fail-open: no failure inside the logging — wiring, body tee, MDC adapter, emission, metrics — can ever fail, delay or alter the call it describes (Common guide §8.2).
What the exchange line looks like — the message, the structured document, the arrival line — is
Common guide §4; what the module
deliberately does not do (no rates or latencies as metrics, no retries, no body masking transformers, no
replaying body cache, no hand-built clients) is
Common guide §8.1. A hand-built
RestClient or RestTemplate gets the interceptor bean added by the host (§3.2).
1.2 The WebClient twin¶
The module is the reference implementation for the WebClient twin: it owns the message text and the
blocking stack's outcome vocabulary, and the cross-stack contract files — configuration reference, field
family and index mapping — live in the repository-shared /docs, bound by both builds. The contract and
the lockstep tests that pin it are
Common guide §9.2. A host that uses
both clients (a servlet application with a WebClient for streaming calls) may carry both modules, each
logging the client it serves.
2. Architecture¶
2.1 Component overview¶
Six Kotlin files in one package, eu.inqudium.legatium.restclient.logging, plus the shared layer, in
five layers:
┌──────────────────────────────────────────────────────────────────────────────┐
│ Auto-configuration │
│ ClientLoggingAutoConfiguration │
│ ├─ RestClientCustomization (RestClientCustomizer, late) │
│ └─ RestTemplateCustomization (RestTemplateCustomizer, late) │
│ ClientLoggingProperties · HeaderLogProperties (both shared) │
├──────────────────────────────────────────────────────────────────────────────┤
│ Client lifecycle │
│ ClientRequestLoggingInterceptor (ClientHttpRequestInterceptor) │
│ └─ CapturingClientHttpResponse (ClientHttpResponse) ◀ emission on close │
├──────────────────────────────────────────────────────────────────────────────┤
│ State and emission │
│ Exchange │
│ ExchangeLogEmitter ──▶ ClientLogField (shared) │
│ ClientLoggingMetrics │
├──────────────────────────────────────────────────────────────────────────────┤
│ Capture │
│ BoundedBodyCapture │
├──────────────────────────────────────────────────────────────────────────────┤
│ Cross-cutting (legatium-common, inlined) │
│ ClientLogField · MdcKeys · TraceMdcKeys · MdcScope · Traceparent · Timeouts│
│ NanoTimeSource · CorrelationIdGenerator · reportQuietly · failOpen │
└──────────────────────────────────────────────────────────────────────────────┘
| Class | Responsibility |
|---|---|
ClientLoggingAutoConfiguration |
Registers the interceptor bean, the default NanoTimeSource / CorrelationIdGenerator / HeaderValueMasker, and — when Boot's spring-boot-restclient is present — a late RestClientCustomizer and RestTemplateCustomizer that append the interceptor. |
ClientRequestLoggingInterceptor |
Owns the client side: activation by host and path, fail-open wiring, identity resolution (traceparent first, correlation header on traceless calls) with the traceless header, the request-body capture, the call-wide MdcScope, the breadcrumb, the no-response path, the handoff to the response wrapper. |
CapturingClientHttpResponse |
The response the client gets back: delegates, tees the body the application reads, reports a failure of any delegate operation (status, headers, body open/read/close, response close), and turns close() into the emission point. |
Exchange |
Per-exchange state from entry to emission; the exactly-once guards. |
ExchangeLogEmitter |
Builds and emits the arrival line and the completion event; resolves level, outcome and cause (timeouts via the shared Timeouts); records body sizes; opens the emission MdcScope with trace ownership. |
BoundedBodyCapture |
The bounded capture target; count-only mode with limit 0; the response-side read state (BodyReadState); single-writer/late-reader visibility via a volatile total. |
Shared layer (legatium-common, inlined) |
ClientLoggingProperties / HeaderLogProperties, ClientLogField, ClientLoggingMetrics, ClientActivation, MdcScope, Traceparent, Timeouts, the injectable collaborators and the fail-open guards — one implementation for both twins, class by class in Common guide §9.1. |
2.2 Auto-configuration and registration¶
ClientLoggingAutoConfiguration is listed in
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports and is conditional on
adapter-logging.enabled (default true) only — no web application type: a batch job that calls out is a
client too. It registers:
| Bean | Condition | Purpose |
|---|---|---|
NanoTimeSource |
@ConditionalOnMissingBean |
NanoTimeSource.SYSTEM |
CorrelationIdGenerator |
@ConditionalOnMissingBean |
CorrelationIdGenerator.DEFAULT (counting generator: random per-instance base-36 prefix + counter, 21 chars — ADR-0004) |
HeaderValueMasker |
@ConditionalOnMissingBean |
HeaderValueMasker.forKey(properties.maskingKey) — the length:hash fingerprint, HMAC-keyed when masking-key is set; the one bean both twins mask with (the interceptor's constructor defaults to the same, so manual wiring honours the key too) |
ClientRequestLoggingInterceptor |
@ConditionalOnMissingBean |
the interceptor, built from the bound properties and the host's MeterRegistry (ObjectProvider; an empty, no-op CompositeMeterRegistry without one) |
RestClientCustomizer |
@ConditionalOnClass(RestClientCustomizer), @Order(LOWEST_PRECEDENCE - 10) |
builder.requestInterceptor(interceptor) on every RestClient.Builder Boot hands out |
RestTemplateCustomizer |
@ConditionalOnClass(RestTemplateCustomizer), same order |
appends the interceptor to every RestTemplate built through RestTemplateBuilder |
Because the interceptor is its own bean, a host can replace it while keeping the customizers
(Common guide §3). Boot's spring-boot-restclient module is an optional dependency:
without it the interceptor bean still exists and the host attaches it by hand (§3.2).
2.3 Lifecycle of one exchange¶
RestClient.retrieve()/exchange() or RestTemplate.execute()
│ (client observation opened; traceparent injected by the host's propagation)
▼
InterceptingClientHttpRequest ──▶ [earlier interceptors] ──▶ ClientRequestLoggingInterceptor.intercept
│
├─ shouldNotFilter(uri)? ──yes──▶ execution.execute (untouched pass-through)
│
├─ wireExchange ──throws──▶ execution.execute (fail-open, stage=wiring)
│ • request id: traceparent trace id, else header on the request, else generated
│ and ADDED to the request (ADR-0002)
│ • request body captured from the byte array (bounded / count-only)
│ • response capture created if logging OR measuring is on
│ • request headers selected and masked (multi-value, comma-joined, AFTER the
│ correlation header was added)
│ • traceId/spanId parsed from the traceparent header
│ • startNanos read from NanoTimeSource; gauge exchanges.open += 1
│
├─ MdcScope(requestId, method, target) opened (fail-open: no scope on failure)
├─ logRequestStart if enabled
│
└─ try response = execution.execute(request, body) ← the wire call
return CapturingClientHttpResponse(response, capture, hooks)
catch exchange.failure = e; WARN breadcrumb; completeExchange; rethrow
finally MdcScope.close() (guarded separately)
… the client's converters read the body through the tee; the client closes the response …
CapturingClientHttpResponse.close()
delegate.close() (connection back to the pool first)
completeExchange(exchange) → exactly-once CAS; gauge exchanges.open -= 1
ExchangeLogEmitter.logExchange → exactly-once CAS, then the event
The emitter computes duration, reads the final status and headers off the real response, classifies
level/outcome/cause, records body sizes, gates on the logger level, opens the emission MdcScope (with
trace ownership, see Common guide §7.6), selects the response headers, decodes the captured
bodies and writes one event.
2.4 Emission point: response close¶
Emitting when the interceptor returns would report a response nobody has read yet: a body of zero bytes,
a duration that excludes the read, and — for the client's own error handling, which reads the body of a
4xx/5xx to build its exception — a picture of the exchange that is simply not over. RestClient and
RestTemplate both close the response in a finally when they are done with it, so the response's
close() is the moment the exchange truly ends. The interceptor therefore hands back a
CapturingClientHttpResponse whose close() completes the exchange:
- after the message converters read the body (through the tee),
- after the client's status handlers read the body for their exception,
- for a streaming result (
exchange(..., close = false), anInputStreamResourcebody), when the application closes it.
So the logged status, response headers and captures are final. Two consequences:
adapter_duration_msmeasures response occupancy including the body read, not bare round-trip time (§4.2).- Everything rests on the response being closed. The gauge
adapter.logging.exchanges.openmakes that assumption measurable (§4.3); the exactly-once CAS onExchange.completedmakes a double close harmless.
A call that produces no response — connection refused, DNS failure, a timeout before the status line
— emits right away from the interceptor's catch block: -> - in the message, no status field,
adapter_outcome=failure (or timeout, §4.5), the exception
attached as the cause. A short WARN breadcrumb with the exception's toString is logged first on the
module's own logger (eu.inqudium.legatium.restclient.logging.ClientRequestLoggingInterceptor) — not on
the exchange logger (one event per call is that stream's contract) — and the exception is rethrown
unchanged for the client to map (ResourceAccessException and friends).
2.5 The body tee¶
Bodies are never pre-read, buffered or replayed:
- The request body is what the interceptor is handed:
RestClientandRestTemplatebuffer the outgoing body into a byte array before the interceptor chain runs, so the capture simply copies (up tomax-body-bytes) and counts it at wiring time — before the wire call. It is complete and final by construction, but it is what the client is about to send, not what reached the peer: the interceptor API has no seam at the actual write. The fieldadapter_request_bodyis documented as exactly that (and is the evidence a refused call leaves); the meteradapter.request.body.size, documented as bytes that flowed, records its sample only for an exchange that received a response — the one proof this seam has that the request went out. There is no read state on the request side. - The response body is teed as the application reads it:
CapturingClientHttpResponse.getBody()wraps the delegate's stream once; everyreadcopies (up to the limit) and counts; the body counts as consumed to its end when the application sees the EOF or when the byte count reaches the length the response declared — a trustworthyContent-Length, i.e. none with aContent-Encoding, handed to the capture at handover. The second rule exists because Spring'sByteArrayHttpMessageConverterreads exactlyContent-Lengthbytes withreadNBytesand never asks for the EOF (the engines' streams return0for that final zero-length read, not-1); without it everybyte[]answer counted aspartial. The declared length is peer-controlled input and is treated as such: it only ever feeds the completeness comparison — never an allocation, a read or a wait — and a non-numeric value is folded to "unknown" rather than counted as a wiring failure. Nothing is withheld, so streaming behaviour and the connection pool's view of the body are those of an unwrapped response. BoundedBodyCaptureis the target: aByteArrayOutputStreamof at mostmax-body-bytesand a total byte counter. With limit0it runs in count-only mode for the body-size meters. Visibility from the reading thread to the closing thread (usually the same; not necessarily) is established by the capture itself: the volatiletotalBytesis written last in every mutation.
The captures exist only when a body is logged (in any mode — on-failure needs the bytes before the outcome is known, Common guide §6.3) or measured; without either, the response wrapper still
exists (the close hook is the emission point), but the body stream passes through with the read-failure
guard only.
The capture mirrors consumption, not transmission. The log shows exactly the bytes the application
actually read — no more. A response body the application never opens (toBodilessEntity(), a
ResponseEntity<Void>) is logged as absent and records no size sample, even though the peer sent one; a
body read only partially is captured to exactly that extent, and the [truncated, N bytes total] note
counts what flowed, not Content-Length. This is the deliberate trade-off against a replaying buffer —
the log tells the truth about what the application processed, and streaming stays untouched. Because of
that, the log cannot tell a body the peer sent but the application dropped from one that was never sent;
the counter adapter.response.body.read (Common guide §7.4) exists for exactly that distinction.
2.6 MDC coverage¶
The module advertises "call identity in MDC while the wire call runs". Concretely:
| Thread / phase | Mechanism | Covered |
|---|---|---|
| The wire call (inner interceptors, the request factory, the HTTP engine's own logging) | call-wide MdcScope in intercept |
yes |
| The body read and the close, after the interceptor returned | — the client's converters run in the caller's context | no (the caller's ambient MDC applies — usually the same thread, with its inbound identity) |
| The emission at close | MdcScope in the emitter, with trace ownership |
yes |
MdcScope is an additive overlay: it puts the three adapter_* keys and restores the previous values
on close (threads are pooled; an inbound request's filter may own other keys). Around the call it leaves
the trace keys alone — a tracing bridge's own scope is authoritative there. Around the emission it
owns them: a parsed id is installed, an unparsed one is removed for the scope's lifetime, so a stale
bridge id on the closing thread can never join the event to a foreign trace.
The one thing the overlay never does is replace: endpoint_request_id (Limesium) and every other
ambient key stay visible on the client line, which is how inbound and outbound lines join without either
library knowing about the other.
2.7 Fail-open contract¶
A logging component must never fail the call it describes. The module enforces that at every boundary
where it calls host-provided code (MDC adapter, appenders, MeterRegistry, the client's request and
response objects):
| Stage | Where | What happens on failure | Counted as |
|---|---|---|---|
| wiring | wireExchange (correlation bean, header selection, capture construction) |
the interceptor degrades to a plain pass-through for this call | failopen{stage=wiring} |
| wiring | MdcScope open |
the call runs without call MDC | failopen{stage=wiring} |
| wiring | MdcScope close |
restoration lost; never masks an exception propagating out of the call | failopen{stage=wiring} |
| wiring | body-size recording, operational counter updates | the event follows without the sample / the count | failopen{stage=wiring} |
| arrival | logRequestStart (including the level gate) |
the arrival line is dropped | failopen{stage=arrival} |
| emission | logExchange — everything after the exactly-once CAS, including the status read |
the exchange event is lost; the close returns normally | failopen{stage=emission} |
| registration | ClientLoggingMetrics.registerOrFallback |
the conflicting meter lives in a private registry, warned once per name | — |
What the promise behind the stages is, where the reports go (the module's own loggers, never the
exchange logger), why the exchange log is an observability feature and not an audit trail, and why the
boundary is Exception and not Throwable, is one contract for both twins —
Common guide §8.2. Specific to this stack: an Error
thrown by the logging backend during the emission at response close is outside the promise and reaches
the client's finally.
3. Using it in a foreign project¶
Everything that is one contract for both twins — prerequisites, the dependency, overriding beans, the logging backend and structured output, the index mapping, the configuration and the metrics — is written once, in the Common guide. This chapter holds what is specific to the interceptor: how it is wired into a Boot application, how to wire it by hand, where it sits in the chain, and how to verify the integration.
3.1 Automatic wiring¶
The shipped activation is not the interceptor bean but the two customizers that attach it. The hooks are
Boot's builder Spring beans, both defined in the spring-boot-restclient module:
| Boot bean | Defined by | Scope | When the customizers run |
|---|---|---|---|
RestClient.Builder |
RestClientAutoConfiguration |
prototype — every injection point receives a fresh builder, so one adapter's baseUrl or default headers never leak into another's |
on creation, before the builder is handed out: every RestClientCustomizer bean, in bean order |
RestTemplateBuilder |
RestTemplateAutoConfiguration |
singleton, immutable — each configuring call returns a new builder | at build(): every RestTemplateCustomizer bean, in bean order, on the freshly built RestTemplate |
This module contributes one customizer of each kind, both ordered at Ordered.LOWEST_PRECEDENCE - 10:
the RestClientCustomizer does exactly builder.requestInterceptor(interceptor), the
RestTemplateCustomizer appends the interceptor to the template's interceptor list — in both cases the
interceptor lands at the end of the list, innermost
(§3.3).
Consequently the rule for the host is: every adapter obtains its client from the injected builder
bean. Constructor injection is the usual form; a @Bean method parameter or a builder obtained from
the ApplicationContext is the same bean with the same customizers applied.
@Service
class ThingsAdapter(builder: RestClient.Builder) { // Boot's RestClient.Builder bean, injected
private val client = builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build()
fun thing(id: Long): Thing =
client.get().uri("/things/{id}", id).retrieve().body(Thing::class.java)!!
}
@Service
class LegacyThingsAdapter(builder: RestTemplateBuilder) { // Boot's RestTemplateBuilder bean, injected
private val template = builder.rootUri("https://api.example.com").build()
fun thing(id: Long): Thing = template.getForObject("/things/{id}", Thing::class.java, id)!!
}
Covered by the automatic wiring:
- every
RestClientbuilt from an injectedRestClient.Builder, however manybuild()calls the adapter makes on it; - every
RestTemplatebuilt through the injectedRestTemplateBuilder, whatever chain of configuring calls precedes thebuild(); - every HTTP service client group Boot builds through its
RestClient.Builder(HttpServiceClientAutoConfiguration,@ImportHttpServices) — the proxies' underlying client carries the interceptor like any other.
Not covered — these clients never meet Boot's customizers and therefore log nothing:
RestClient.create()/RestClient.create(baseUrl)and the staticRestClient.builder();- a
RestTemplateconstructed directly (RestTemplate(),RestTemplate(requestFactory)); - a builder the host constructs and then customises itself.
For those, §3.2 applies.
The automatic wiring is conditional on two things, both pinned by ClientLoggingAutoConfigurationTest:
adapter-logging.enabled (default true; false removes the interceptor bean and both customizers
together), and Boot's customizer classes being present (@ConditionalOnClass on each nested
configuration) — without spring-boot-restclient both back off silently while the interceptor bean
remains. Note that since Boot 4 the web starters no longer pull spring-boot-restclient: a host with
only spring-boot-starter-web has RestClient and RestTemplate on the classpath (from spring-web)
but neither Boot's builder beans nor the customizer contracts — it adds spring-boot-starter-restclient,
or wires by hand. The wiring itself is fail-open like everything else: a failure inside the interceptor's
setup for a call degrades that call to a pass-through with a stage=wiring report
(§2.7); the customizers cannot fail in a way that breaks a builder.
To confirm the attachment at runtime — in a test or a startup check — read the builder's interceptor
list; the module's interceptor must be the last entry (a built RestTemplate exposes the same through
interceptors):
val builder: RestClient.Builder = context.getBean(RestClient.Builder::class.java)
builder.requestInterceptors { interceptors -> check(interceptors.last() is ClientRequestLoggingInterceptor) }
3.2 Manual wiring¶
The interceptor bean ClientRequestLoggingInterceptor exists in every enabled context; only its
attachment depends on Boot's builders. Attach it yourself when a client does not pass through them:
| Situation | Why the automatic wiring does not reach it |
|---|---|
The host builds clients by hand — RestClient.create(...), the static RestClient.builder(), a RestTemplate constructed directly, or a builder it constructs itself |
Boot's customizers run only on the builder beans Boot defines; a client built elsewhere never sees them |
spring-boot-restclient is absent — the host depends on spring-web directly, or only on spring-boot-starter-web, without spring-boot-starter-restclient |
both nested customizer configurations are @ConditionalOnClass and back off; there are no builder beans either, so every client is hand-built anyway |
| A builder obtained from Boot is customised after the customizers ran and the logging interceptor must stay innermost | interceptors the host appends on that builder land behind this one and run inside it (§3.3); where the logged request must be what those later interceptors produce, the host takes over the ordering |
| A client is built outside a Spring context — a library's own client, an integration test without Boot | there is no context to hold the bean, so the interceptor is constructed directly (below) |
The mechanics are one line per client: inject the bean and append it as the last interceptor, so it sits closest to the wire and sees the request as the peer receives it, once per attempt of any retry outside it:
@Configuration(proxyBeanMethods = false)
class ThingsClientConfiguration {
@Bean
fun thingsClient(loggingInterceptor: ClientRequestLoggingInterceptor, auth: AuthenticationInterceptor): RestClient =
RestClient.builder()
.baseUrl("https://api.example.com")
.requestInterceptor(auth) // outside: its header is what gets logged
.requestInterceptor(loggingInterceptor) // last = innermost, closest to the wire
.build()
@Bean
fun legacyTemplate(loggingInterceptor: ClientRequestLoggingInterceptor): RestTemplate =
RestTemplate().apply { interceptors = interceptors + loggingInterceptor }
}
Rules for manual wiring:
- Reuse the one bean; do not construct a second interceptor in a Boot context. The meters are
identified by name, so every interceptor on one
MeterRegistryshares one metrics owner and theadapter.logging.exchanges.opengauge reports the total across them (Common guide §7.4). A second instance would not break anything, but it buys nothing. - Honour the switch. With
adapter-logging.enabled=falsethe bean does not exist, and a plain injection point fails to start the context. A client configuration that must survive the switch takes anObjectProvider<ClientRequestLoggingInterceptor>and attaches the interceptor only if it is available:
@Bean
fun thingsClient(loggingInterceptor: ObjectProvider<ClientRequestLoggingInterceptor>): RestClient =
RestClient.builder()
.baseUrl("https://api.example.com")
.also { builder -> loggingInterceptor.ifAvailable { builder.requestInterceptor(it) } }
.build()
- Activation is not the host's business. Host and path activation (
adapter-logging.exclude-hosts,include-path-patterns,exclude-path-prefixes) is evaluated inside the interceptor (Common guide §6.4), so a manually attached interceptor applies the same rules as an automatically attached one. There is no need to attach it selectively. - Ordering is the host's business. The automatic wiring guarantees "innermost" by its late
customizers; a manual
requestInterceptor(...)call is appended wherever it is made. Put it last.
Outside a Spring context the interceptor is constructed directly. The constructor takes the bound
properties, the time source, the id generator and a MeterRegistry, plus an optional trailing
HeaderValueMasker — when omitted, the masker the properties' masking-key selects, exactly as the
auto-configuration's default bean, so a configured key is honoured however the interceptor is built — all
defaults are public:
val interceptor = ClientRequestLoggingInterceptor(
ClientLoggingProperties(), // every default; or a copy(...) with the fields to change
NanoTimeSource.SYSTEM,
CorrelationIdGenerator.DEFAULT,
SimpleMeterRegistry(), // or the registry the surrounding code owns
)
val client = RestClient.builder().baseUrl(url).requestInterceptor(interceptor).build()
Everything else is unchanged by the way the interceptor was attached: emission point, outcomes, meters, the call-wide MDC, header sections, body capture and the fail-open contract behave exactly as under the automatic wiring — the interceptor does not know how it got onto the chain.
3.3 Interceptor order and other interceptors¶
The customizers are ordered at Ordered.LOWEST_PRECEDENCE - 10, so the interceptor is appended behind
the interceptors of earlier customizers and of the builder's own configuration, and runs inside them —
closest to the wire:
- an authentication interceptor outside it has already added its header, so the logged (and masked) request headers are what the peer receives;
- a retrying interceptor outside it invokes it once per attempt — one line per attempt, each an honest crossing (§4.7);
- interceptors a host adds after the customizers ran (directly on a builder it obtained from Boot) run inside this one and are outside that guarantee — they see the request after this interceptor did.
The traceparent header is not affected by the order at all: the client observation Boot registers
injects it into the request before any interceptor runs (Common guide §7.6).
Activation is evaluated in the interceptor (shouldNotFilter), so its semantics are byte-identical
with the WebClient twin. If the host needs a different position, it attaches the bean itself
(§3.2).
3.4 Verifying the integration¶
- Make any call through a Boot-built
RestClient:
restClientBuilder.baseUrl("https://httpbin.org").build().get().uri("/get").retrieve().body(String::class.java)
Expect one adapter-http-exchange line with adapter_request_id=…. Without tracing configured, the
peer received an X-Correlation-Id with that id (httpbin echoes request headers in its body). With
Micrometer Tracing configured, expect traceId=… spanId=… on the line and no X-Correlation-Id
at the peer (ADR-0002).
-
Log something between building and executing the request from inside an inner interceptor, or enable the engine's debug logging, and confirm
adapter_request_idis on those lines too. -
Point the client at a closed port and confirm: an immediate WARN breadcrumb on
eu.inqudium.legatium.restclient.logging.ClientRequestLoggingInterceptor, then the exchange line with-> -,adapter_outcome=failureat ERROR with the cause attached. -
Check the meters (with actuator):
curl -s localhost:8080/actuator/metrics/adapter.logging.events
curl -s localhost:8080/actuator/metrics/adapter.logging.exchanges.open
events should equal the number of logged lines; exchanges.open should be 0 when idle.
4. Special characteristics¶
4.1 Differences to the WebClient twin¶
Everything not listed here behaves exactly as in legatium-webclient-logging.
| Concern | This module | WebClient twin |
|---|---|---|
| Disposition vocabulary | success / failure / timeout |
plus cancelled — a cancelled subscription (a downstream timeout() operator, a take, a disposed caller) is the reactive reality a blocking call cannot have |
| Emission point | response close | the response body's terminal signal |
| Never-completing exchange | a response the application never closes | a response body nobody subscribes to or releases |
| Request body | the byte array the client hands the interceptor — complete, captured at wiring before the wire call (the field shows what was about to be sent; the size meter records only once a response proves it went out) | teed at the connector's writeWith as the inserter writes it |
| Call-wide MDC | thread-local, for the wire call | none — the call hops event-loop threads; emission MDC and the message inline only |
| Read failure mid-body | IOException from the tee stream, reported and rethrown |
the body Flux's error signal |
| URI template | recorded by RestClient; never by RestTemplate |
recorded by WebClient |
| Attachment | RestClientCustomizer + RestTemplateCustomizer |
WebClientCustomizer |
4.2 Duration is response occupancy¶
adapter_duration_ms runs from wiring (before the wire call) to response close (after the body was read
and the client was done). A peer that answers the status line fast but streams the body slowly is slow
by this measure — which is the truth an operator wants, and the same rule Limesium applies inbound
(request occupancy). Bare round-trip latency is what http.client.requests already measures.
4.3 A response that is never closed¶
The emission rests on the response being closed. RestClient's retrieve(), body(...), toEntity(...)
and exchange(..., close = true) (the default) close in a finally; RestTemplate closes in
doExecute's finally. The one path that does not is a raw exchange(fn, close = false) — or a
streaming result (InputStreamResource, a ResponseEntity<InputStream>) where RestClient deliberately
hands the open stream to the caller. There the caller owns the close, and until it happens the exchange
stays open on the gauge adapter.logging.exchanges.open. A monotonically growing baseline is the
signal that responses are leaking — a resource leak in the host, visible through the module's liveness
meter before it becomes a pool exhaustion.
4.4 Failures on the response the client was handed¶
The status line arrived, then the connection died mid-body (a reset, a read timeout while streaming). The
tee reports the IOException to the exchange and rethrows it unchanged; at close the event is
adapter_outcome=failure (or timeout) with the status that was received — "200 but failed" is exactly
what happened, and hiding either half would mislead. The captured prefix of the body is logged as far as
it flowed.
The same holds for every other operation on the response that can fail the caller: opening the body,
asking for status, status text or headers (the snapshot at handover tolerates a refusing engine and logs
-> -, but the client's own later access propagates), available(), closing the body stream, and the
response's own close() — a pooled connection that cannot be returned throws there, immediately before
the emission in the finally. Each is recorded on the exchange before the exception propagates unchanged,
so the caller and the event never disagree: a response that failed the caller is never logged as
success, and a throwing close yields exactly one event, a failure carrying that exception.
4.5 Timeouts and how they are recognised¶
A timeout is the one client-side disposition an operator reads differently from every other failure (the
peer is slow, not broken), so it has its own outcome value at WARN. The shared Timeouts classification
walks the exception's cause chain and, per link, the class hierarchy by name: the JDK's
SocketTimeoutException, java.net.http.HttpTimeoutException (and its connect subtype) and
java.util.concurrent.TimeoutException are matched as types; Netty's io.netty.handler.timeout.
TimeoutException family and its connect timeout io.netty.channel.ConnectTimeoutException (a
ConnectException, which no JDK timeout type covers) by their fully qualified names, so the WebClient
twin recognises Reactor Netty's read and connect timeouts without a Netty dependency here. Engines wrap their timeouts (IOException over
SocketTimeoutException; RestClient wraps once more into ResourceAccessException after the
interceptor saw the original) — hence the chain walk. Anything else is a plain failure.
4.6 RestTemplate has no URI template¶
RestClient records the URI template of a call made through uri(String, Object...) as a request
attribute (org.springframework.web.client.RestClient.uriTemplate, mirrored by the module and pinned
against the client by UriTemplateAttributeTest) — that is adapter_url_template, the low-cardinality
aggregation half of the path pair. RestTemplate sets its template only on the observation context, not
on the request, so RestTemplate calls log the path alone and their body meters fall under
uri=UNKNOWN. A host that wants the template on RestTemplate calls migrates to RestClient; the
module does not reconstruct templates by guessing.
4.7 Retries yield one line per attempt¶
The interceptor sits innermost (§3.3), so a retrying
interceptor (or a resilience decorator around the client) invokes it once per attempt. Each attempt is a
crossing and gets its own line — with the same adapter_request_id under a trace, or a new generated
id per attempt on a traceless call (each attempt wires afresh and the retried request already carries the
first attempt's correlation header only if the retrying layer reuses the mutated request object; a rebuilt
request gets a new id). Dashboards counting calls per peer count attempts; http.client.requests does
the same.
Tracing making every call traced, the one-metrics-owner-per-registry rule and the masking fingerprint are one behaviour for both twins — Common guide §7.6, §7.4 and §6.2; the shared code they rest on is Common guide §9.
5. Appendix¶
5.1 File map¶
legatium-restclient-logging/
├── pom.xml library deps only
├── README.md module summary, field family, property table
├── docs/
│ ├── GUIDE.md this document
│ └── api-module.md the module page of the Dokka API reference
└── src/
├── main/kotlin/eu/inqudium/legatium/restclient/logging/
│ ├── ClientLoggingAutoConfiguration.kt beans, the two late customizers
│ ├── ClientRequestLoggingInterceptor.kt the interceptor: activation, wiring, call scope, no-response path
│ ├── CapturingClientHttpResponse.kt response wrapper: body tee, failure report for every delegate call, close = emission
│ ├── Exchange.kt per-exchange state and the exactly-once guards
│ ├── ExchangeLogEmitter.kt arrival line and completion event
│ └── BoundedBodyCapture.kt bounded capture target, read state
│ (ClientLoggingProperties, ClientLogFields, Traceparent, Timeouts, Mdc, NanoTimeSource,
│ CorrelationIdGenerator, HeaderLogProperties, BodyCapture helpers and the fail-open guards
│ live in ../legatium-common - inlined, Common guide §9)
├── main/resources/META-INF/spring/…AutoConfiguration.imports
├── test/java/…/BoundedBodyCaptureFuzzTest.java Jazzer target (regression mode in every build)
└── test/kotlin/eu/inqudium/legatium/restclient/logging/ see the suite overview below
Test-suite overview (the generated test-evidence page lists every test with its rationale):
| Suite | Scope |
|---|---|
Unit suites (ClientRequestLoggingInterceptorTest, …BodyAndHeaderTest, …MetricsTest, BoundedBodyCaptureTest) |
mock request/response driven, deterministic: line format, identity, levels/outcomes, emission at close, activation, tees, meters, fail-open stages |
ClientLoggingAutoConfigurationTest |
the shipped activation: beans, customizers attaching the interceptor to Boot's builders, back-off, the optional-dependency boundary |
ClientRequestLoggingInterceptorIntegrationTest |
end to end through Boot's RestClient.Builder / RestTemplateBuilder and the JDK engine against a real HTTP peer: templates, bodies, the wire correlation header, refused connection, read timeout |
ClientRequestLoggingTracingIntegrationTest |
ADR-0002 beside a real Brave bridge: the injected traceparent, the log-to-trace join, no correlation header on traced calls, every call traced |
Engine suites (RequestFactoryContract run as JdkClientRequestFactoryIntegrationTest, HttpComponentsRequestFactoryIntegrationTest, JettyRequestFactoryIntegrationTest, ReactorNettyRequestFactoryIntegrationTest, SimpleRequestFactoryIntegrationTest) |
the engine-agnosticism contract against every request factory Spring ships: the body tee and its complete read state on the engine's own stream, the wire correlation header, the engine's real read and connect timeout types as timeout through RestClient's wrapper (the connect timeout provoked by a loopback tarpit, Tarpit), a refused connection as the failure control, and per engine whether a gzip answer reaches application and log decoded (JDK client via Spring's compression support, Apache HC5, Jetty) or as sent (Reactor Netty, HttpURLConnection) |
Lockstep/contract tests (TwinContractTest, UriTemplateAttributeTest) |
pin the message text, this stack's outcome vocabulary and the mirrored RestClient attribute; the shared literals (SharedContractTest), the field/template and configuration/reference lockstep (ClientLogFieldTest, ClientLoggingReferenceConfigTest, ClientLoggingPropertiesTest) and the metrics owner's registration behaviour (ClientLoggingMetricsTest) live once in legatium-common; the shaded jar itself is exercised by the standalone consumer-smoke/ build |
Fuzzing of the shared Traceparent parser and header masking lives in legatium-common; the bounded
capture's fuzz target lives here.
5.2 Related documents¶
- Common guide — everything that is one contract for both twins: prerequisites, dependency, beans, logging backend, index mapping, configuration, fields, MDC keys, meters, trace correlation, scope and fail-open guarantees, the shared code.
README.md— module summary, field family, property table, meters.legatium-webclient-logging/README.md— the twin's documentation; everything not listed in §4.1 applies there unchanged./docs/adapter-logging-reference.yml— the complete commented configuration reference, bound by both twins./docs/elk/README.md— the Elasticsearch component template for theadapter_*fields./docs/adr/— the decision records: fuzzing signal, trace identity, shared core, id generator, headers masked by default, bodies logged by outcome, theadaptervocabulary.- Limesium — the inbound sibling: same design,
endpoint_*fields,endpoint-logging.*namespace.