Legatium — Common guide¶
One structured adapter_* log line per outbound HTTP exchange, for two Spring client stacks that share
one contract: the blocking twin legatium-restclient-logging
(RestClient, RestTemplate) and the reactive twin
legatium-webclient-logging (WebClient) emit the same message
format, the same field family, bind the same adapter-logging.* configuration and register the same six
meters. The inbound counterpart of the family is the sibling project
Limesium.
This guide holds everything that is one contract by construction — integration, the exchange line,
configuration, the fields, the MDC keys, the meters, the trace correlation, the fail-open promise and the
shared code behind all of it — so that it is written once. Everything specific to a stack lives in the
twin's own guide: the RestClient guide (architecture of
the interceptor, its wiring, the blocking stack's special characteristics) and the
WebClient guide (architecture of the filter, its wiring,
the reactive stack's special characteristics). Everything here is derived from the code under
legatium-common/src/main/kotlin/eu/inqudium/legatium/common/ and the twins' entry points; when the
two disagree, the code wins.
Table of contents¶
- Prerequisites
- Adding the dependency
- Overriding beans
- Logging backend and structured output
- Index mapping (ELK)
- Configuration
- Property reference
- Header sections
- Body logging and body measuring
- Activation: hosts and paths
- Logger levels
- Validation at startup
- Example configurations
- Metrics and observation
- Log fields
- MDC keys
- Levels and outcomes
- Meters
- Reading the meters together
- Trace correlation
- Scope and guarantees
- What the modules deliberately do not do
- Fail-open contract
- Shared code: legatium-common, inlined by Shade
- The shared classes
- The twin contract and its lockstep tests
1. Prerequisites¶
| Requirement | Why |
|---|---|
| Spring Boot 4.x, Java 21 | the auto-configurations hook Boot 4's customizer contracts (org.springframework.boot.restclient.*Customizer, org.springframework.boot.webclient.WebClientCustomizer); on Boot 3 the interceptor or filter bean would exist but never be attached |
Boot's client module — spring-boot-restclient (via spring-boot-starter-restclient) for the RestClient twin, spring-boot-webclient (via spring-boot-starter-webclient or spring-boot-starter-webflux) for the WebClient twin |
the builder beans and the customizer contracts the automatic wiring rests on; optional for the modules — without it the bean stays and the host attaches it by hand (manual wiring in the twin's guide). Since Boot 4 the web starters no longer pull spring-boot-restclient |
| Clients built through Boot | the interceptor or filter is attached to the injected builder bean (RestClient.Builder, RestTemplateBuilder, WebClient.Builder) and to every HTTP service client group built from one; a client built by hand (RestClient.create(), WebClient.create(), a RestTemplate() constructed directly) gets nothing unless the host adds it |
| An HTTP engine or connector | the RestClient twin is engine-agnostic (JDK HttpClient by default, Apache HttpComponents, ...); the WebClient twin is connector-agnostic and pinned against Reactor Netty, the JDK HttpClient, Jetty and Apache HttpComponents 5 by its connector suites |
| SLF4J 2.x binding with an encoder that renders key-value pairs and the MDC | the fields ride SLF4J's fluent addKeyValue, the identity rides the MDC; Boot's default console pattern prints only the message — §4 |
A MeterRegistry in the host (actuator) — optional |
the six meters are consumed from it, never exported (ADR-0008); without one the meters are no-ops (an empty CompositeMeterRegistry) |
| The index mapping composed before the first event — for an ELK target | the component template in /docs/elk/ keeps body and header fields out of the dynamic mapping (§5) |
| Kotlin stdlib | the modules are written in Kotlin; a Java host needs nothing extra, the jars pull kotlin-stdlib transitively |
Both modules are libraries, not starters: each declares spring-boot-autoconfigure, slf4j-api,
micrometer-core, kotlin-stdlib, its client API (spring-web resp. spring-webflux and
reactor-core) and its optional Boot client module — no logging backend, no YAML, no engine or
connector are forced onto the host.
2. Adding the dependency¶
<dependency>
<groupId>eu.inqudium</groupId>
<artifactId>legatium-restclient-logging</artifactId> <!-- or legatium-webclient-logging -->
<version><!-- current release: see the badges below --></version>
</dependency>
The current release is shown live by the Maven Central badges:
— there is no BOM; the version is declared on the dependency itself. An application may carry both
twins (a servlet host using
RestClient for most calls and WebClient for a streaming one gets both
logged, in one format); keep them at the same version, both jars inline the same shared classes
(ADR-0003).
That is all: the auto-configuration registers the interceptor or filter and its customizers (the twin
guides describe the automatic and the manual wiring), every call through a Boot-built client is logged
on the adapter-http-exchange logger at INFO, the request id comes from the traceparent trace id
(traceless calls send an X-Correlation-Id instead —
ADR-0002), and the six meters are registered in the
host's MeterRegistry if one exists.
To remove a module again without touching the classpath:
adapter-logging:
enabled: false
3. Overriding beans¶
Time, randomness and masking are injected, not ambient — three fun interfaces, each a
@ConditionalOnMissingBean bean of both auto-configurations, each driven in the modules' tests from an
AtomicLong, a fixed string or a lambda without any mocking library:
NanoTimeSource— monotonic nanoseconds foradapter_duration_msand the slow threshold; the single production read ofSystem.nanoTime()isNanoTimeSource.SYSTEM. Log timestamps come from the logging backend, keeping the two time domains separate.CorrelationIdGenerator— the id for traceless calls without a correlation header;DEFAULTis the counting generator of ADR-0004: a random per-instance base-36 prefix plus one atomic increment per call, 21 characters, noSecureRandomon a request thread or an event loop. Never consulted for a traced call (ADR-0002: thetraceparenttrace id is the request id) — and in a host with tracing configured, never at all (§7.6).HeaderValueMasker— how a header listed in amaskedsection renders on the line; the default,HeaderValueMasker.forKey(masking-key), is the stablelength:hashfingerprint, HMAC-keyed when the key is set (§6.2). The properties decide WHICH values are masked, the bean decides HOW — a keyed HMAC for a compliance regime, a fixed***for a host that wants no correlation at all.
Every default is @ConditionalOnMissingBean, and one host bean serves both twins — the shared types
live in the package eu.inqudium.legatium.common:
@Configuration(proxyBeanMethods = false)
class ClientLoggingCustomisation {
/** Deterministic ids in a test profile, or a different id format (a peer that insists on UUIDs). */
@Bean
fun correlationIdGenerator(): CorrelationIdGenerator =
CorrelationIdGenerator { UUID.randomUUID().toString() }
/** A keyed fingerprint where an unkeyed hash is not acceptable; both twins mask with this one bean. */
@Bean
fun headerValueMasker(secrets: Secrets): HeaderValueMasker =
HeaderValueMasker { value -> "hmac:" + secrets.hmacSha256Hex(value).take(16) }
/** Only if the host owns a monotonic clock abstraction already. */
@Bean
fun nanoTimeSource(clock: MonotonicClock): NanoTimeSource =
NanoTimeSource { clock.nanos() }
}
Why the back-off is reliable — and where it ends. @ConditionalOnMissingBean is only trustworthy on
an auto-configuration, and that is what both ClientLoggingAutoConfiguration classes are: registered
through META-INF/spring/…AutoConfiguration.imports, they are imported after every @Configuration
class and component scan of the host has registered its bean definitions, so a host bean of the same
type is always there when the condition is evaluated. The two twins define the three collaborators
under identical bean names and types; with both on the classpath the second auto-configuration (the
WebClient twin, alphabetically) backs off by type, which is what keeps the default
spring.main.allow-bean-definition-overriding=false from failing the context — pinned by the
consumer-smoke build, which starts both shaded jars in one context. The back-off is pinned per twin by
ClientLoggingAutoConfigurationTest. The guarantee ends where the host bean itself lives in another
auto-configuration (a platform starter that ships a CorrelationIdGenerator): the order between
auto-configurations is then undefined, and if that one runs after Legatium's, two beans of the type exist
and the interceptor's or filter's constructor injection fails with a NoUniqueBeanDefinitionException.
Such a starter orders itself with @AutoConfigureBefore(ClientLoggingAutoConfiguration::class) — one
annotation per twin it wants to precede — or marks its bean @Primary.
A host-defined ClientRequestLoggingInterceptor (RestClient twin) or ClientRequestLoggingFilter
(WebClient twin) bean replaces the entry point, not the wiring: the auto-configured customizers
still attach it to every Boot-built client. Both constructors take
(ClientLoggingProperties, NanoTimeSource, CorrelationIdGenerator, MeterRegistry) plus an optional
trailing HeaderValueMasker — when omitted, the masker the properties' masking-key selects
(HeaderValueMasker.forKey), so a hand-built entry point honours a configured key exactly like the
auto-configured one; the property is the one source of truth, and only an explicitly passed masker (a
host bean) overrides it:
@Bean
fun clientRequestLoggingInterceptor(
properties: ClientLoggingProperties,
nanoTime: NanoTimeSource,
ids: CorrelationIdGenerator,
registry: MeterRegistry,
): ClientRequestLoggingInterceptor = ClientRequestLoggingInterceptor(properties, nanoTime, ids, registry)
Attaching the (default or replaced) entry point to a client that does not pass through Boot's builders is the manual-wiring section of the twin's guide. Keep in mind the one-instance-per-registry rule of the open-exchanges gauge (§7.4).
4. Logging backend and structured output¶
On the logger adapter-http-exchange (configurable) a completed exchange reads, in a plain-text
appender:
Adapter http exchange POST https://api.example.com/things/42 -> 200 [adapter_request_id=4bf92f3577b34da6a3ce929d0e0e4736 traceId=4bf92f3577b34da6a3ce929d0e0e4736 spanId=00f067aa0ba902b7]
The message text is the same on both stacks (TwinContractTest pins it in each twin). The trace suffix
appears only when the outgoing request carried a conformant W3C traceparent header — its trace id
then doubles as the request id (ADR-0002); a call that produced no response reads -> -. With
log-request-start on, a second, earlier line precedes it:
Adapter http exchange started POST https://api.example.com/things/42 [adapter_request_id=4bf92f…]
The arrival line carries no outcome, status or duration, so a dashboard keyed on adapter_outcome still
sees exactly one event per call.
The modules emit through SLF4J's fluent API. Every exchange event carries its data in two places, and an encoder treats them differently:
| Data | Carried as | Examples |
|---|---|---|
| The field family | SLF4J key-value pairs (addKeyValue) |
adapter_outcome, adapter_duration_ms, adapter_url_host, adapter_response_body |
| The identity and trace context | MDC entries, set by the emission scope (and, on the blocking stack, by the call scope around the wire call) | adapter_request_id, adapter_method, adapter_route, traceId, spanId (from the traceparent header) |
A plain %msg pattern shows neither — only the message, which repeats the gist inline
(… -> 200 [adapter_request_id=…]) precisely for that case. Logback offers three ways to render the
rest; which one fits depends on where the output goes.
Option 1 — PatternLayout with %kvp and %mdc (text, for terminals and files)¶
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg %kvp{NONE} [%mdc]%n</pattern>
</encoder>
</appender>
13:54:58.534 INFO [http-nio-8080-exec-3] adapter-http-exchange - Adapter http exchange POST https://api.example.com/things/42 -> 200 [adapter_request_id=4bf9… traceId=4bf9… spanId=00f0…] adapter_outcome=success adapter_duration_ms=17 adapter_request_method=POST adapter_response_status_code=200 adapter_url_host=api.example.com adapter_url_path=/things/42 adapter_url_template=https://api.example.com/things/{id} [adapter_method=POST, adapter_request_id=4bf9…, adapter_route=https://api.example.com/things/42, endpoint_request_id=4bf9…, traceId=4bf9…, spanId=00f0…]
(On the reactive stack the thread reads reactor-http-epoll-2 or the like; the line is otherwise identical.)
%kvpquotes values with double quotes by default;%kvp{NONE}leaves them bare.%X{adapter_request_id:-}prints one key and nothing when it is absent;%mdcprints every entry that is present askey=value, so the trace keys appear only on traced calls.- In Spring Boot the same pattern goes into
logging.pattern.consolewithout any XML. - This is the modules' own test configuration (
src/test/resources/logback-test.xml). - Text output renders values raw. The logged path and query are percent-encoded as sent, but bodies (opt-in) may contain line breaks — mind that before pointing a text appender at a pipeline that parses lines.
Option 2 — Logback's JsonEncoder (JSON without an extra dependency, Logback ≥ 1.4.3)¶
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.JsonEncoder">
<withSequenceNumber>false</withSequenceNumber>
<withNanoseconds>false</withNanoseconds>
</encoder>
</appender>
One JSON object per event, control characters escaped — but the key-value pairs arrive as a list of
single-key objects and the MDC nested under "mdc":{…}. Correct and safe, yet awkward to map onto the
flat adapter_* fields of the index template; suitable for local JSON inspection, not for an index.
Option 3 — Spring Boot structured logging (JSON, flat, typed — recommended for an index)¶
logging:
structured:
format:
console: ecs # or logstash, gelf
level:
adapter-http-exchange: INFO
eu.inqudium.legatium.restclient.logging: WARN # resp. eu.inqudium.legatium.webclient.logging
Key-value pairs and MDC entries become flat top-level fields, and values keep their JVM type —
adapter_duration_ms is a number, adapter_response_status_code a number, the shape each field
declares in ClientLogField and the component template maps. This is the shape the template in
§5 is written for. logging.structured.json.include / exclude / rename
control the field selection (e.g. to drop adapter_route, which duplicates host and path).
The exchange at the top of this section becomes this document:
{
"message": "Adapter http exchange POST https://api.example.com/things/42 -> 200 [adapter_request_id=4bf92f3577b34da6a3ce929d0e0e4736 traceId=4bf92f3577b34da6a3ce929d0e0e4736 spanId=00f067aa0ba902b7]",
"level": "INFO",
"logger": "adapter-http-exchange",
"adapter_outcome": "success",
"adapter_duration_ms": 17,
"adapter_request_method": "POST",
"adapter_response_status_code": 200,
"adapter_url_host": "api.example.com",
"adapter_url_path": "/things/42",
"adapter_url_template": "https://api.example.com/things/{id}",
"adapter_request_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"adapter_method": "POST",
"adapter_route": "https://api.example.com/things/42",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"endpoint_request_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
The adapter_request_id / adapter_method / adapter_route / traceId / spanId entries come from
the MDC (§7.2); the adapter_* key-values are the field family of
§7.1. The endpoint_request_id is not the modules': it is the ambient MDC of the
inbound request the call was made from (Limesium), which the additive emission scope leaves in place —
on the blocking stack as a matter of course, on the reactive stack when the host's context propagation
restores it around the completing operator — and this is how the client line joins the server line
without any coupling between the two libraries. How MDC entries land in the document (flat, nested,
renamed) is the encoder's decision.
| Option | Output | Key-value pairs | MDC | Typed values | Escapes control chars | Use for |
|---|---|---|---|---|---|---|
1 PatternLayout %kvp %mdc |
text | inline k=v |
inline k=v |
no (all text) | no | terminals, local files, tests |
2 JsonEncoder |
JSON | list of objects | nested mdc |
partly | yes | local JSON inspection |
3 StructuredLogEncoder |
JSON | flat fields | flat fields | yes | yes | log index (ELK etc.) |
Whatever the option, keep the module's own logger (eu.inqudium.legatium.restclient.logging resp.
eu.inqudium.legatium.webclient.logging) at WARN or lower: it carries the WARN breadcrumb on a thrown
call (blocking stack) and the modules' own failure reports.
5. Index mapping (ELK)¶
The thirteen adapter_* fields have a ready-made Elasticsearch component template in
/docs/elk/:
curl -X PUT "$ES/_component_template/legatium-restclient-logging-fields" \
-H 'Content-Type: application/json' \
--data-binary @docs/elk/legatium-restclient-logging-fields.component-template.json
Compose it into the data-stream mapping before the first event arrives — an unmapped body or header
field would be mapped dynamically and become searchable, which the payload fields' index: false
deliberately prevents. The MDC-carried keys are intentionally not in the template: where they land
depends on the host's encoder layout; map them where the encoder configuration lives. The one template
serves both twins (the field family is one enum, ClientLogFieldTest keeps it in lockstep) and composes
beside Limesium's endpoint_* template without collision.
6. Configuration¶
All properties live under adapter-logging.*. The complete, commented reference with every default is
/docs/adapter-logging-reference.yml;
ClientLoggingReferenceConfigTest (in legatium-common) binds it against the shared
ClientLoggingProperties and fails the build on any drift — every key must exist, every value must be
the built-in default. Both twins bind that one class, so the namespace is identical across the stacks
by construction.
6.1 Property reference¶
| Property | Type | Default | Meaning |
|---|---|---|---|
enabled |
boolean | true |
Master switch. false makes the auto-configuration back off — no interceptor or filter, no customizers, no beans. A context-start decision, not a runtime toggle. |
logger-name |
string | adapter-http-exchange |
Logger of the arrival line and the exchange event. Its level is the runtime volume control (§6.5). Distinct from Limesium's http-exchange by design. |
correlation-id-header |
string (RFC 9110 token) | X-Correlation-Id |
Header read from a traceless request (no conformant traceparent — ADR-0002); when absent, or outside the acceptance rule (at most 200 visible-ASCII characters, ADR-0002 amendment), an id is generated and SET on the request under this name so the peer can quote it. A traced call takes its request id from the traceparent trace id, ignores this header and adds nothing. |
include-query-string |
boolean | true |
Log the query string as its own field adapter_url_query (never part of the path). Disable when query parameters may carry personal data. |
log-request-start |
boolean | false |
Additionally log an arrival line before the call, at INFO, under the emission MDC. Carries no outcome/status/duration. |
include-path-patterns |
list of PathPattern |
[] |
Request paths the logging is active for at all, whatever the host; empty = every call. Parsed once at startup; an invalid pattern fails the context. |
exclude-path-prefixes |
list of strings | [] |
Request-path prefixes skipped entirely — no event, no MDC, no correlation header, no gauge movement. Prefix match against the decoded path. An exclude always wins over an include. |
exclude-hosts |
list of strings | [] |
Peer hosts skipped entirely (case-insensitive, without port) — the outbound counterpart of excluding a health probe. |
slow-request-threshold |
duration | 5s |
At/above this duration an INFO call escalates to WARN and is flagged adapter_slow: true; the outcome stays success. Measured until the exchange is truly over — response close (RestClient) or the body's terminal signal (WebClient): response occupancy, not bare round-trip time. Must be ≥ 1 ms. |
request-headers.includes / .excludes / .masked / .unmasked |
lists of header names | see §6.2 | The request-header section. |
response-headers.includes / .excludes / .masked / .unmasked |
lists of header names | see §6.2 | The response-header section. |
log-request-body |
never | on-failure | always |
never |
Log the request body into adapter_request_body, up to max-body-bytes — on every line (always) or only when the outcome is not success or the status is a 4xx (on-failure, §6.3). On the blocking stack this is the serialized body the client hands to the wire call — copied before the call, so a refused connection still shows what was about to be sent; on the reactive stack it is teed as the inserter writes it to the connector. |
log-response-body |
never | on-failure | always |
never |
Tee the response body into adapter_response_body as the application reads it, up to max-body-bytes — on every line or only when the outcome is not success or the status is a 4xx. |
measure-request-body-size |
boolean | false |
Record adapter.request.body.size; independent of log-request-body. On the blocking stack a sample is recorded only for an exchange that received a response — the one proof the interceptor's seam has that the request went out (§7.4). |
measure-response-body-size |
boolean | false |
Record adapter.response.body.size and adapter.response.body.read; independent of log-response-body. |
max-body-bytes |
int > 0 | 16384 |
Capture limit per body. Bounds memory (and, on the reactive stack, the tee's transient copy per buffer), not the exchange: bytes beyond it still flow; the logged value is truncated with a note of the total size. |
masking-key |
string | (empty) | Keys the masking fingerprint: empty keeps the unkeyed length:hash, any other value turns it into an HMAC-SHA256 under the key — same shape, same stability under the same key, guess-proof without it. A secret: supply it like one; the properties' toString redacts it. Ignored when a host pins its own HeaderValueMasker bean. |
6.2 Header sections¶
Each direction has one section with four lists; matching is case-insensitive throughout. The section
is masked by default (ADR-0005): whatever it logs is
rendered as a fingerprint unless the name is explicitly allowed in plaintext, so the debugging move
includes: ["*"] costs readability, never confidentiality.
| List | Semantics |
|---|---|
includes |
Names to log. Empty logs nothing (the safe default). The entry * logs every header the message carries; names are deduplicated case-insensitively on both paths. |
excludes |
Names removed from the included set — meaningful mainly with *. An exclude always wins. * is rejected here at binding time (an empty includes already logs nothing). |
masked |
Names whose value is replaced by what the HeaderValueMasker bean renders — by default a fingerprint length:hex, the character length plus the first 64 bits of the SHA-256 of the UTF-8 value, e.g. secret-token renders as 12:930bbdc51b6aed5c (a pseudonym, not anonymisation: equal values stay recognisable as equal; key it with masking-key to stop guess confirmation). Default ["*"]: every logged header is masked (ADR-0005). Narrow it to names, or empty it to switch masking off — a visible decision. Masking affects only headers that are logged; listing a name here does not include it. |
unmasked |
Names that appear in plaintext although masked covers them — the explicit allowlist of harmless names (Content-Type, Accept, a correlation id). An unmasked name always wins over a masked one. * is rejected here: the plaintext set is a list of names by design; to log everything in plaintext, empty masked instead. |
Multi-valued headers are joined with ,. The selected pairs are rendered into one display-only field
per direction as [Name:"value", Name2:"value2"]; nothing is emitted when the selection is empty or no
selected header is present.
Request headers are selected at wiring time from the outgoing request, after the correlation header
was added, so a selected X-Correlation-Id shows what actually went out; response headers at
emission (response close, resp. the body's terminal signal), so they reflect the response as received.
Masking is a fingerprint, not a secret. By default masked replaces a header value with
length:sha256-prefix64 — stable, so a masked token can still be correlated across events, across the
two twins, and across the Limesium server line (same scheme), and a 64-bit cryptographic prefix makes
accidental collisions negligible. It is unsalted and unkeyed: it prevents plaintext exposure, not
offline guessing. A reader with a candidate list (usernames, tenant names, short API keys) can confirm a
candidate by hashing it. Do not treat the default as a security boundary for guessable values; omit such
headers from the selection instead — or key it: masking-key turns the fingerprint into an
HMAC-SHA256 under the key, same shape and stability, guess-proof without the key (a secret — supply it as
one). For any other shape the masker is the HeaderValueMasker bean (§3): a host
pins its own (a fixed *** for no correlation at all) once, and both twins mask with it. The contract a
replacement must keep: never return the plaintext.
6.3 Body logging and body measuring¶
Per direction, a mode decides whether a body is logged and a flag decides whether its size is measured — independent of each other:
log-*-body |
measure-*-body-size |
Capture installed | Buffered | Effect |
|---|---|---|---|---|
never |
off | no | — | request body untouched; response passes through (the blocking stack keeps a read-failure guard, the reactive stack the terminal hooks) |
always |
off | yes, limit max-body-bytes |
up to the limit | field logged on every line; no size sample |
on-failure |
off | yes, limit max-body-bytes |
up to the limit | field logged only when adapter_outcome is not success or the status is a 4xx; no size sample |
never |
on | yes, limit 0 (count-only) |
nothing | size sample recorded; no field |
always / on-failure |
on | yes, limit max-body-bytes |
up to the limit | both |
on-failure is the volume switch (ADR-0006).
always means every body of every call; what is nearly always wanted is bodies for the calls that went
wrong — failure, timeout, on the reactive stack cancelled — which cuts the volume by orders of
magnitude and hits exactly the lines a body is wanted for. The response side decides at emission, when
the outcome is final. The request body flows before the outcome is known, so on-failure captures it
exactly like always does (bounded by max-body-bytes) and discards it for a success: the capture is
paid, the output is saved — and the output is what burdens the log pipeline. The gate is wider than the
outcome vocabulary (§7.3) by one status class: a 4xx answer keeps its
success outcome — the peer answered — but its bodies are logged in on-failure, because the client's
error is exactly what the body explains; a 5xx is failure and logs as well. A slow but healthy call
stays success and logs no bodies.
A decoding failure is the application's, not the exchange's. Both clients decode the body after
it flowed: the RestClient converters run above the interceptor, the WebClient decoders downstream of
the body tee. When Jackson cannot map a 200 answer to the requested type, the exchange has already
seen a clean stream, and the line says success with status 200 while the caller receives a
RestClientException resp. DecodingException. Neither client offers a seam through which that failure
could reach the module, so this is the one case where the line's outcome and the caller's outcome differ
- and on-failure therefore logs no body for it. To see what a peer really sends when the
application cannot make sense of it, set log-response-body: always for the duration of the analysis;
the field then carries the raw bytes the converter read. Pinned by a test in each twin.
Rules that hold for every combination:
- The captures are passive: bytes are counted and (up to the limit) copied as they flow — the request body from the byte array the client hands the interceptor (blocking stack) or as the inserter writes it to the connector (reactive stack), the response body as the application reads it; nothing is pre-read, replayed or withheld. Backpressure and streaming behaviour are untouched.
- An unread response body is logged as absent; no size sample is recorded — on the reactive stack the exchange does not complete until something consumes or releases the body.
- Zero-byte bodies produce no field and no sample — the distribution describes bodies that exist.
- Truncation is byte-bounded, and the decoder leaves an incomplete trailing multi-byte sequence
undecoded rather than rendering a replacement character:
…<prefix>... [truncated, 12345 bytes total]. - The log charset is the one the
Content-Typedeclares (request: the caller's; response: the peer's), UTF-8 when absent or unparsable. measure-*records what actually flowed, exact beyondmax-body-bytes. Field and meter are deliberately decoupled on the blocking stack's request side: the interceptor sees the serialized body before the wire call, so the field shows the body the client handed to the call (evidence, also for a refused connection), while the meter records a sample only once a response proves the request went out.measure-response-body-sizeadditionally recordsadapter.response.body.read— whether the application consumed the body completely, partially, or not at all (§7.4).- On the reactive stack the captures are frozen at emission: a body chunk still in flight after a
cancellation can no longer change what was logged. A zero-copy file upload (Reactor Netty's
sendfile) keeps its path under request capture; its bytes are counted, not copied.
Cardinality of the body meters. The size summaries and the read-state counter are tagged uri and
host. The uri tag is the URI template the client recorded — and only when it carries a placeholder:
both clients record whatever string was passed to uri(String, ...), so a concatenated
uri("/things/" + id) would otherwise put one tag value per id on the meter; such values fold to
UNKNOWN (as does every RestTemplate call, which records no template). The host tag is
caller-controlled and is not folded: a host that fans out to many peer hosts (webhooks, per-tenant
endpoints) should leave the measuring properties off or accept one tag set per host in its registry.
6.4 Activation: hosts and paths¶
active(uri) = uri.host not in exclude-hosts
AND (include-path-patterns is empty OR any pattern matches uri.path)
AND no exclude-path-prefix is a prefix of uri.path
An inactive call passes through without any trace: no correlation header, no MDC, no event, no gauge
movement, no counters (on the reactive stack the connector receives the caller's very request object).
The activation is one shared implementation (ClientActivation), so its semantics are identical on both
stacks by construction. Typical use:
adapter-logging:
exclude-hosts:
- pushgateway.monitoring.svc
- config-server
exclude-path-prefixes:
- /internal/
include-path-patterns uses Spring's PathPattern syntax (/api/**, /api/{*rest}) against the request
path whatever the host; exclude-path-prefixes is a prefix match; exclude-hosts an exact,
case-insensitive match on the URI's host without port. Path matching sees the target the way a server
router would — segments decode for matching — so /%61pi/things is included by /api/**,
/api%2Fthings is not, and /%61ctuator/health is excluded by /actuator/health. The logged
adapter_url_path stays raw.
6.5 Logger levels¶
Severity and semantic are decoupled: the level only decides how loud — and whether — a line is emitted;
adapter_outcome carries the disposition (§7.3). The level of the
logger-name logger therefore acts as the runtime volume control:
adapter-http-exchange level |
Emitted |
|---|---|
INFO |
every call |
WARN |
failures (5xx), timeouts, slow calls, cancellations (reactive stack) — and thrown or errored calls |
ERROR |
only calls that threw or errored (no response, or the body read failed) |
OFF |
nothing — and no event is even assembled |
Level and outcome are resolved before the event is built, so a disabled level costs no assembly, no
header selection, no body decoding. Metrics are recorded before the level gate and are unaffected by
it — except adapter.logging.events, which by definition counts emitted events only.
6.6 Validation at startup¶
ClientLoggingProperties.init and HeaderLogProperties.init reject, with a message naming the property:
- blank
logger-nameorcorrelation-id-header; - a
correlation-id-headerthat is not an RFC 9110 token (it is written onto every traceless request; an engine or connector that validates field names — the JDK client does — would reject a non-token per call, failing the CALL, not merely the log line); max-body-bytes≤ 0;- a blank (whitespace-only)
masking-key— empty means unkeyed, whitespace is a worthless secret; slow-request-threshold< 1 ms (the logged duration has millisecond resolution);- blank entries in any list (
exclude-hostsincluded); *in anexcludesor anunmaskedlist;- an unparsable
include-path-patternsentry (parsed once at construction of the entry point).
6.7 Example configurations¶
Minimal production profile — everything logged, telemetry peers excluded, slow threshold tightened:
adapter-logging:
exclude-hosts:
- pushgateway.monitoring.svc
slow-request-threshold: 2s
logging:
level:
adapter-http-exchange: INFO
eu.inqudium.legatium.restclient.logging: WARN # resp. eu.inqudium.legatium.webclient.logging
Diagnostics profile — headers with masked credentials, both bodies, arrival lines:
adapter-logging:
log-request-start: true
log-request-body: always
log-response-body: always
max-body-bytes: 16384
request-headers:
includes: ["*"]
excludes: [Cookie]
unmasked: [Accept, Content-Type, X-Correlation-Id] # everything else stays a fingerprint
response-headers:
includes: [Content-Type, Content-Length, Retry-After]
unmasked: [Content-Type, Content-Length, Retry-After]
Production profile with bodies — bodies only for the calls that went wrong; the request body is
captured up to max-body-bytes per call and dropped on success:
adapter-logging:
log-request-body: on-failure
log-response-body: on-failure
max-body-bytes: 4096
Metrics without log volume — body sizes and consumption measured, only failures logged:
adapter-logging:
measure-request-body-size: true
measure-response-body-size: true
logging:
level:
adapter-http-exchange: WARN
7. Metrics and observation¶
7.1 Log fields¶
The structured fields of the completion event (the arrival line carries method, host, path, template,
query and request headers without outcome/duration/status). The index types are those of the shared
component template; ClientLogFieldTest in legatium-common keeps the shared enum in lockstep with it.
| Field | Type | Index | doc_values | When present | Notes |
|---|---|---|---|---|---|
adapter_outcome |
keyword | yes | on | always | success / failure / timeout, plus cancelled on the reactive stack — the field dashboards split by; decoupled from the level |
adapter_duration_ms |
long | yes | on | always | from the injected monotonic source; until response close (RestClient) resp. the body's terminal signal (WebClient) |
adapter_request_method |
keyword | yes | on | always | |
adapter_response_status_code |
short | yes | on | when a response arrived | absent for a refused connection, a timeout before the status line, or a cancellation before the response (-> -) |
adapter_url_host |
keyword | yes | on | when the URI has a host | host or host:port — the outbound coordinate |
adapter_url_template |
keyword | yes | on | when the client recorded a template | the aggregation half of the path pair, e.g. https://api.example.com/things/{id}; never for RestTemplate |
adapter_url_path |
keyword | yes | off | always | the raw path as sent, ids and all — filter exactly, never group |
adapter_url_query |
keyword | yes | off | when the request had one and include-query-string is on |
raw, as sent |
adapter_slow |
boolean | yes | on | only when the threshold was reached | absence means fast |
adapter_request_headers |
keyword | no | off | when selected headers are present | display only, rendered [Name:"value", …] |
adapter_response_headers |
keyword | no | off | when selected headers are present | display only |
adapter_request_body |
keyword | no | off | when log-request-body admits the outcome and the client handed a non-empty body to the wire call (blocking stack — present even when the call was refused) resp. bytes were written to the connector (reactive stack) |
display only, bounded |
adapter_response_body |
keyword | no | off | when log-response-body admits the outcome and bytes were read |
display only, bounded |
Each field declares the exact JVM type of its value (ClientLogField), which the lockstep test checks
against the template; the emitters are the only writers.
The throwable of a failed call is attached to the event as its cause (setCause), so a structured
encoder renders the stack trace alongside the fields.
7.2 MDC keys¶
Set by MdcScope around each emission — and, on the blocking stack, around the wire call:
| Key | Value | Scope |
|---|---|---|
adapter_request_id |
the request id: the traceparent trace id, or the accepted/generated correlation id (ADR-0002) — always set |
emission; on the blocking stack also the call |
adapter_method |
the HTTP method | same |
adapter_route |
the request target: scheme://host[:port]/path, query excluded — for an outbound call the host is as much part of the route as the path |
same |
traceId |
trace id from traceparent |
emission only (owned) |
spanId |
parent-id from traceparent — the local client span the peer will see as its parent |
emission only (owned) |
MdcScope restores the previous value of every key on close, rolls back a partial install if the
adapter throws mid-put, and restores best-effort on close with the first failure rethrown and later ones
suppressed. It never removes keys it does not own: an inbound request's identity stays. The reactive
stack has no call-wide thread-local scope — the call hops event-loop threads; propagating the identity
into reactive operators is the host's context-propagation business.
7.3 Levels and outcomes¶
Resolved in this order in each twin's ExchangeLogEmitter:
| Condition | Level | adapter_outcome |
|---|---|---|
| the call threw or errored and a timeout is in the cause chain (or among the suppressed exceptions) | WARN |
timeout |
| the call threw or errored (no response), or the body read threw or errored | ERROR |
failure |
the subscription was abandoned by the caller — reactive stack only (a timeout operator, a disposed caller, a disconnect; a consumer that stops reading the body because it has read enough is success) |
WARN |
cancelled |
| status ≥ 500 without an exception (the peer answered; the application decides) | WARN |
failure |
| otherwise | INFO |
success |
… and the duration reached slow-request-threshold |
INFO → WARN |
unchanged, plus adapter_slow: true |
A 4xx is a success at INFO: the peer answered as designed, and whether a 404 is a problem is the
application's call — the status is on the line for the dashboard to split by. Slowness raises severity;
it never turns a completed call into a failure.
7.4 Meters¶
Six meter families under seven meter names, all consumed from the host's MeterRegistry (an ObjectProvider; without one the
auto-configuration passes an empty CompositeMeterRegistry, whose meters are Micrometer no-ops) — one
shared implementation (ClientLoggingMetrics), parameterised by the stack. Why these six and no others,
and the rule for adding one, is ADR-0008. All fixed-tag meters are pre-registered at construction, so a rate()
alert sees the zero before the first occurrence. Rates, latencies and status distributions are
deliberately left to http.client.requests and the log fields.
| Meter | Type | Tags | Meaning |
|---|---|---|---|
adapter.logging.failopen |
counter | stage = emission | arrival | wiring |
Logging failures the fail-open path swallowed. emission: an exchange event was lost. arrival: a start line was lost. wiring: bookkeeping failed (pass-through degradation, a lost sample or counter) — the event usually still follows. A lost log line cannot report itself through the same pipeline; this counter is the independent channel. |
adapter.logging.events |
counter | outcome = success | failure | timeout (| cancelled on the reactive stack) |
Exchange events actually emitted on the exchange logger — after the level gate, arrival lines excluded. The reconciliation ground truth against the log index. |
adapter.logging.exchanges.open |
gauge | client = restclient | webclient |
Exchanges between entry (wiring) and the exactly-once completion — response close, resp. the body's terminal signal. Hovers near the in-flight call count in health. Tagged per twin so that a host carrying both twins gets two gauges instead of Micrometer silently keeping the first one registered; sum over client for the total. |
adapter.logging.correlation.id |
counter | source = trace | header | generated |
Origin of each call's request id (ADR-0002). A re-entry by a retrying outer interceptor with the id generated on attempt 1 keeps counting generated. |
adapter.response.body.read |
counter | uri = template with a placeholder, UNKNOWN otherwise; host; state = unread | partial | complete |
How far the application consumed the response body, opt-in via measure-response-body-size. Recorded once per call that received a response — including bodiless consumption, which is the unread share the counter exists to show. partial = consumption started but the end of the body was never observed (a converter that stopped early, an exception mid-read, a consumer that stopped reading). On the blocking stack the end is observed either as the EOF or as the byte count reaching a trustworthy declared Content-Length (none with a Content-Encoding), because Spring's ByteArrayHttpMessageConverter reads exactly that many bytes and never asks for the EOF. Created lazily per tag set on first use. |
adapter.request.body.size / adapter.response.body.size |
distribution summary, base unit bytes |
uri, host |
Bytes that actually flowed, opt-in via measure-*-body-size, independent of body logging and level. Exact beyond max-body-bytes. Zero-byte bodies record no sample. On the blocking stack the request sample is recorded only for an exchange that received a response: the interceptor copies the serialized body before the wire call and has no seam at the actual write, so a response is its one proof that the bytes went out (a refused connection or connect timeout records nothing; the reactive stack tees at the connector's write and needs no such rule). Created lazily per tag set on first use. |
One instance per registry and stack. Micrometer deduplicates meters by id, so a second metrics owner against the same registry would share the counters but not the gauge — the second gauge registration is silently ignored. Every entry point therefore obtains its metrics owner through a per-registry cache; several interceptors or filters on one registry share one owner, and the gauge reports the total across them.
Registration conflicts. Micrometer rejects a registration whose id already exists with a different
meter type. Rather than aborting the context (at construction) or suppressing an exchange event (at the
lazy body-size registration), the conflicting meter falls back to a private registry, warned once per
meter name on eu.inqudium.legatium.common.ClientLoggingMetrics: the module keeps working and that
meter is simply not exported. The gauge has a second case Micrometer does not reject: a gauge of the
same id already registered by the host or by another copy of the library (another classloader) would be
returned as-is and the module's state silently dropped — the gauge would show the foreign value. The
registration therefore checks for an existing meter under its exact name and client tag first and takes
the same private-registry path with the same warning: visibly degraded, never silently wrong.
7.5 Reading the meters together¶
The meters are designed to cover each other's blind spots:
| Question | Signal |
|---|---|
| Are exchange events being lost loudly (something threw)? | failopen{stage=emission} > 0 |
| Are exchange events being lost silently (nothing threw; a response was never closed, a body never consumed)? | exchanges.open baseline grows monotonically instead of returning towards 0 |
| Is the log pipeline (appender, broker, index) losing events? | sum(adapter.logging.events) over a window ≠ count of indexed adapter-http-exchange documents for the same window |
| Did the application stop propagating identity onto its calls? | the generated share of correlation.id rises (in a host with tracing configured it is zero by construction — §7.6) |
| Are callers abandoning their own calls (operator timeouts, disconnects)? — reactive stack | events{outcome=cancelled} rises while timeout does not |
| Is a call site discarding the payload it paid for? | the unread or partial share of response.body.read{uri=...,host=...} rises |
| Are payloads growing beyond what the log captures? | body.size percentiles vs. max-body-bytes |
| Which dependency is slow, or failing? | adapter_url_host on the log line, split by adapter_outcome — not a meter of this module; http.client.requests has the latency histogram |
A suggested alert set:
# lost exchange events (hard failure)
increase(adapter_logging_failopen_total{stage="emission"}[5m]) > 0
# silently stuck exchanges (liveness) - tune the bound to the service's outbound concurrency
min_over_time(adapter_logging_exchanges_open[15m]) > 50
# callers tearing down their own calls (reactive stack)
sum(rate(adapter_logging_events_total{outcome="cancelled"}[10m])) > 0
# timeouts per peer (from the log index, not a meter): adapter_outcome=timeout by adapter_url_host
Note on the gauge: a response the application holds open deliberately (a streaming download) is intended to stay open — that is the liveness signal working, not a leak to suppress.
7.6 Trace correlation¶
The modules read the outgoing W3C traceparent header, put on the request by the host's tracing
propagation:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
└──────── traceId ───────────────┘ └──── spanId ────┘
traceIdis the trace the client span runs under, published under Boot's logging-correlation keytraceId, so the log-to-trace join holds.- The header's parent-id is the span the peer will treat as its parent — which IS the local client span
of this call. It is published under Boot's local-span key
spanId. (Inbound, Limesium publishes the same header field asparentSpanId, because there it is the caller's span; the two projects are consistent, not identical.) - Parsing follows the W3C Trace Context Recommendation strictly: lowercase hex of fixed length, no
all-zero ids, version
ffforbidden, version00exactly four fields, higher versions parsed by the version-00 rules for their first four fields. A non-conformant header is ignored — nothing is logged, and the call counts as traceless for the identity decision. The conformance is pinned bytraceparent/conformance.txt, the same fixture Limesium uses. - Since ADR-0002 the trace id also is the call's
adapter_request_id, and a traced call gets no correlation header — the identity decision and the trace fields share the one strict parse.
Where the header comes from. With Micrometer Tracing on the classpath, Boot registers the client
observation (RestClientObservationAutoConfiguration resp. the RestTemplate variant;
WebClientObservationAutoConfiguration); when the observation starts, the tracing handler opens the
client span and injects traceparent — into the request headers before the interceptor chain runs
(RestClient), into the request builder before the request is built and the filter chain runs
(WebClient) — so every interceptor or filter, this one included, sees the header. Each twin's tracing
integration test pins that order beside a real Brave bridge; a Boot upgrade that changes it breaks the
build rather than silently dropping trace ids. The observation roots a trace whenever none is active (on
the reactive stack: none on the subscribing context), so this holds for every call, sampled or not — an
unsampled trace still propagates, with flags 00. Consequence: in a host with tracing configured,
every call is traced, the module never generates an id there — the generated share of
adapter.logging.correlation.id reads zero by construction — and the peer never receives an
X-Correlation-Id from this module. A peer without tracing that needs a quotable id in that setup is a
matter for the host's propagation configuration (baggage), not for the modules, which stay neutral.
8. Scope and guarantees¶
8.1 What the modules deliberately do not do¶
- No request rates, latencies or status distributions as metrics. Boot's
http.client.requestsand the structured log fields cover those; the modules' meters observe only what those cannot show (§7.4, ADR-0008). - No retries, no circuit breaking, no request rewriting. The modules observe; the one thing they add to a request is the correlation header on a traceless call without one (§7.6).
- No body masking transformers and no per-key response sampling. Bodies are logged verbatim up to the capture limit, and the logger level is the only volume control (§6.5).
- No replaying body cache. The body tees are passive; an unread response body is logged as absent (§6.3).
- No exporting of a
MeterRegistry. The host's registry is consumed if present; otherwise the meters are no-ops (an emptyCompositeMeterRegistry). - No call-wide thread-local MDC on the reactive stack. A reactive call hops event-loop threads; there the identity rides the emission scope and the message (§7.2).
- No clients built by hand. The customizers cover every client built through Boot's builders (and the HTTP service client groups built from them); a hand-built client gets the interceptor or filter bean added by the host — the manual-wiring section of the twin's guide.
8.2 Fail-open contract¶
A logging component must never fail the call it describes. Both modules enforce that at every boundary
where they call host-provided code (MDC adapter, appenders, MeterRegistry, the client's request and
response objects): no failure inside the logging — wiring, body tee, MDC adapter, emission, metrics —
can ever fail, delay or alter the call. Which operation degrades how, and under which stage tag of
adapter.logging.failopen it is counted, is the stage table of each twin's guide; the promise behind
the tags is the same:
wiring— the entry point degrades to a plain pass-through for this call, or a piece of bookkeeping (a sample, a counter, the gauge) is lost while the event still follows;arrival— the arrival line is dropped;emission— the exchange event is lost; the call completes normally.
Every catch block reports through reportQuietly, which swallows a failure of the diagnostics channel
itself (a throwing Counter, a throwing appender that also covers the internal logger).
InterruptedException is caught separately and the interrupt flag is restored. Failures of the logging
are reported on the modules' own loggers
(eu.inqudium.legatium.restclient.logging.ClientRequestLoggingInterceptor resp.
eu.inqudium.legatium.webclient.logging.ClientRequestLoggingFilter, …ExchangeLogEmitter,
eu.inqudium.legatium.common.ClientLoggingMetrics), never on the exchange logger, so the exchange
stream stays parseable. A meter whose registration conflicts with an existing one falls back to a
private registry, warned once per name (§7.4).
Security note. Fail-open is the inverse of what an audit log needs: a host-side fault silently
removes the call from the log instead of failing it. The exchange log is therefore an observability
feature with no completeness guarantee; a regulatory audit trail of outbound calls must come from a
fail-closed component. The compensating controls are adapter.logging.failopen and the
exchanges.open gauge (§7.5) — alert on them.
The boundary is Exception, not Throwable — a decision. Every guard confines an Exception and
lets an Error propagate: a VirtualMachineError, a LinkageError from a broken logging backend or a
StackOverflowError is a JVM-level condition no logging library can meaningfully absorb, and swallowing
it would hide a process that is already failing. The one thing the modules protect against an Error
is their own bookkeeping: a wire call that dies with an Error (an inner interceptor's or filter's
AssertionError, a LinkageError in the engine or connector on first use) still closes the
open-exchange gauge (abandonExchange, no emission attempted, one WARN breadcrumb), so the liveness
signal cannot drift over something the module never caused. An Error thrown by the logging backend
during the emission is outside the promise and reaches the caller — on the blocking stack through the
client's finally around the response close.
9. Shared code: legatium-common, inlined by Shade¶
The byte-identical part of the twins' shared layer lives in the legatium-common module
(ADR-0003). The Maven Shade plugin inlines those
classes into each twin's jar at package time, the dependency-reduced POM drops the dependency, and
legatium-common is never published — consumers keep adding exactly one artifact
(§2), and the shared classes stay internal (-Xfriend-paths; build from
the reactor root or with -am).
Everything whose twin copies genuinely differ stays deliberately duplicated: the emitters and exchanges,
interceptor vs. filter, and BoundedBodyCapture (two different concurrency designs). ADR-0003 names the
threshold: a twin-paired file that reaches 90 % line similarity after neutralising the stack names is
byte-identical enough to move, parameterised where it must differ. For the remainder the accepted cost
is unchanged: a change is a conscious port in both directions, and the lockstep tests catch named
contract drift (keys, field names, meter names, message text), not behavioural drift inside
near-identical code.
9.1 The shared classes¶
| Class | Responsibility |
|---|---|
ClientLoggingProperties / HeaderLogProperties |
The adapter-logging.* binding, validated in init (§6.6) — one class for both twins. HeaderLogProperties is one header section with includes / excludes / masked / unmasked and the masking fingerprint (§6.2); unit-tested and fuzzed here. |
ClientLogField |
The wire names and the exact JVM type of each structured field (§7.1), with the builder extensions the emitters write through; a wrongly typed value drops the field with a warning, never the event. One enum for both twins. |
ClientLoggingMetrics |
The six meters (§7.4), one implementation parameterised by the ClientStack (outcome vocabulary, client tag) — the fixed-tag meters pre-registered, the body meters created lazily per tag, per-meter fallback to a private registry on a registration conflict. Shared since the amendment of 2026-09-04 to ADR-0003, when the twin copies had converged to near-identity. |
ClientActivation |
Which calls are logged at all: host exclusion, include patterns, exclude prefixes (§6.4) — one implementation, so the semantics are identical on both stacks by construction. Shared since the same amendment. |
MdcKeys / TraceMdcKeys / MdcScope |
The MDC key names (§7.2) and the scope that installs them and restores the previous values on close — around the emission with trace ownership, on the blocking stack also around the wire call. |
Traceparent |
Strict W3C traceparent parsing to (traceId, spanId) (§7.6); tests and fuzz target live here. |
Timeouts |
The cause-chain walk that classifies a failure as a timeout (§7.3): the JDK's timeout types matched as types, Netty's by fully qualified name, so neither twin needs a Netty dependency. |
CorrelationHeader |
The acceptance rule for a correlation header found on a traceless request (ADR-0002, §6.1). |
NanoTimeSource / CorrelationIdGenerator / HeaderValueMasker |
The injectable collaborators (§3); SYSTEM, DEFAULT and forKey are the production defaults. |
BodyReadState / decodeTruncated |
The response-side read state behind adapter.response.body.read, and the byte-bounded decoding of a captured body in its declared charset (§6.3). |
reportQuietly / failOpen |
Guard the diagnostics channel (counter + internal log) of every catch block (§8.2). |
9.2 The twin contract and its lockstep tests¶
The RestClient module is the reference implementation; the WebClient module is its twin. The cross-stack contract lives in files both builds bind:
| Contract | Shipped in | Pinned by |
|---|---|---|
| Configuration keys and defaults | /docs/adapter-logging-reference.yml |
ClientLoggingReferenceConfigTest in legatium-common (one ClientLoggingProperties class for both twins, bound against the YAML once) |
| Field family and index mapping | /docs/elk/…component-template.json |
ClientLogFieldTest in legatium-common (one enum for both twins, locked against the template once) |
| Message text, each stack's outcome vocabulary | each twin's emitter and ClientStack |
TwinContractTest in both modules; the meter names, MDC keys and shared literals once in SharedContractTest (legatium-common) |
legatium-common pulls both files from the repository-shared /docs as test resources (declared
in its pom.xml), so a missing checkout fails at resource processing with a clear message rather than
as a silent contract drift. The consequence for a consumer: a dashboard, alert or index mapping written
for one client works unchanged for the other — and an application may carry both modules
(§2).