Limesium — Common guide¶
One structured endpoint_* log line per HTTP exchange, at the service's own boundary. Limesium ships
two modules that do this —
limesium-servlet-logging
for Spring MVC / servlet applications and
limesium-reactive-logging
for Spring WebFlux — with the same message format, the same field family, the same
endpoint-logging.* configuration and the same meters. A dashboard, alert or index mapping must not
care which stack produced an event.
This guide is the part of the documentation that is the same on both stacks: what an exchange line is, the shared architecture, the dependency and encoder setup, the whole configuration namespace, the field family, the meters and the trace contract. Each module has its own guide for everything the stack decides — how the filter is wired and ordered, where the event is emitted, how bodies are teed, what the MDC covers, and the stack's own dispositions and edge cases:
- the servlet guide of the reference implementation, with the per-engine container guide (Tomcat, Jetty, Undertow);
- the reactive guide of the WebFlux twin, including the coroutine variant.
Everything here is derived from the code under limesium-common and the two modules; when the two
disagree, the code wins. Where a section notes a stack difference, §6.1
collects them all.
Table of contents¶
- Introduction
- What the modules do
- What the modules deliberately do not do
- The exchange line
- The two modules and the cross-stack contract
- Shared architecture
- Shared components
- Exchange identity
- The body tee: capture mirrors consumption
- Fail-open contract
- Injectable collaborators
- Using it in a foreign project
- Prerequisites
- Adding the dependency
- Wiring
- Overriding the collaborator beans
- Logging backend and structured output
- Index mapping (ELK)
- Verifying the integration
- Configuration
- Property reference
- Header sections
- Body logging and body measuring
- Path activation
- Logger levels
- Validation at startup
- Example configurations
- Metrics and observation
- Log fields
- MDC keys
- Levels and outcomes
- Meters
- Reading the meters together
- Trace correlation
- Shared characteristics
- Differences between the stacks
- One metrics instance per registry
- Masking is a fingerprint, not a secret
- Shared code: limesium-common, inlined by Shade
- Appendix
- Related documents
1. Introduction¶
1.1 What the modules do¶
Both modules are Spring Boot auto-configured filters — a OncePerRequestFilter plus a
ServletRequestListener on the servlet stack, a WebFilter on the reactive stack. For every inbound
HTTP exchange each of them:
- resolves the exchange identity per ADR-0002: a
conformant
traceparent's trace id is the request id; only a traceless exchange adopts a correlation id from the configured request header (or generates one) and echoes it back on the response — a traced exchange passes through observationally untouched (§2.2); - optionally logs an arrival line the moment the request comes in;
- measures the exchange duration with an injectable monotonic time source;
- optionally tees the request and response bodies as they flow (bounded, never buffered or replayed);
- optionally records the selected request/response headers, with stable masking of sensitive values;
- parses the W3C
traceparentheader at filter entry (traceId/parentSpanId) so the event stays joinable with its trace; - emits exactly one structured completion event on a dedicated logger — with the outcome, status, duration, path, handler template and the optional headers/bodies as SLF4J key-values — at the point where the status is final: request destruction on the servlet stack, the terminal signal (commit-deferred on error) on the reactive stack;
- feeds six Micrometer meters that observe the logging itself (fail-open counts, emitted events, open exchanges, body sizes, request-id origin).
All of this is fail-open: no failure inside the logging — wiring, body tee, MDC adapter, emission, metrics — can ever fail, delay or alter the request it describes (§2.4).
Where the identity is available during the exchange is the one thing the stacks answer
differently: the servlet module puts endpoint_request_id, endpoint_method and endpoint_route into
a thread-local MDC for the whole filter chain (and onto the Spring MVC async worker thread); the
reactive module writes the same keys into the Reactor context and restores them into the handler's MDC
through opt-in context propagation, or natively in its coroutine variant. Each module guide's §1.1
lists what its stack adds.
1.2 What the modules deliberately do not do¶
- No request rates, latencies or status distributions as metrics. Boot's
http.server.requestsand the structured log fields cover those; the modules' meters observe only what those cannot show (§5.4). - No body masking or transformation, and no per-key response sampling. Both were considered and
dropped on purpose; bodies are logged verbatim up to the capture limit. If a body may carry personal
data, leave
log-*-bodyatnever. - No sampling. Every matching exchange emits one event; the logger level is the only volume control
(§4.5), and
on-failurebody logging the only body-volume control (§4.3). - No replaying body cache. The tee is passive; an unread request body is logged as absent — and a body the framework parses itself (a form or multipart request read through the parameter/form-data API) never passes the tee, so it counts as unread (§4.3).
- No exporting of a
MeterRegistry. The host's registry is consumed if present; otherwise a privateSimpleMeterRegistryabsorbs the values.
Each stack has one further non-goal of its own — no MDC on application-owned threads (servlet), no chain-wide thread-local MDC by itself (reactive); see the module guides' §1.2.
1.3 The exchange line¶
On the logger endpoint-http-exchange (configurable) a completed exchange looks like this in a plain-text
appender:
Endpoint http exchange GET /api/things/42 -> 200 [endpoint_request_id=4bf92f3577b34da6a3ce929d0e0e4736 traceId=4bf92f3577b34da6a3ce929d0e0e4736 parentSpanId=00f067aa0ba902b7]
The trace suffix appears only when the request carried a conformant W3C traceparent header — its
trace id then doubles as the request id (ADR-0002). Alongside the message, the event carries SLF4J
key-values that a structured encoder turns into fields:
{
"message": "Endpoint http exchange GET /api/things/42 -> 200 [endpoint_request_id=4bf92f3577b34da6a3ce929d0e0e4736 traceId=4bf92f3577b34da6a3ce929d0e0e4736 parentSpanId=00f067aa0ba902b7]",
"level": "INFO",
"logger": "endpoint-http-exchange",
"endpoint_outcome": "success",
"endpoint_duration_ms": 17,
"endpoint_request_method": "GET",
"endpoint_response_status_code": 200,
"endpoint_url_path": "/api/things/42",
"endpoint_url_template": "/api/things/{id}",
"endpoint_request_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"endpoint_method": "GET",
"endpoint_route": "/api/things/42",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"parentSpanId": "00f067aa0ba902b7"
}
The endpoint_request_id / endpoint_method / endpoint_route / traceId / parentSpanId entries come from
the MDC (§5.2); the endpoint_* key-values are the field family of §5.1.
How MDC entries land in the document (flat, nested, renamed) is the encoder's decision. The servlet
module adds one field, endpoint_async; the reactive module never emits it (§5.1).
With the optional arrival line enabled, a second, earlier line precedes it:
Endpoint http exchange started GET /api/things/42 [endpoint_request_id=0f7c1a2e-...]
The arrival line carries no outcome, status or duration, so a dashboard keyed on endpoint_outcome
still sees exactly one event per exchange.
1.4 The two modules and the cross-stack contract¶
The servlet module is the reference implementation and owns the cross-stack contract files; the reactive module's build binds them:
| Contract | Shipped in | Pinned by |
|---|---|---|
| Configuration keys and defaults | /docs/endpoint-logging-reference.yml — the ONE place the property semantics are documented; the reactive module's docs/endpoint-logging-reference.yml carries exactly its one variant key |
EndpointLoggingReferenceConfigTest in both modules: binds the shared YAML against the module's RequestLoggingProperties (the reactive one also pins that its own file documents nothing but variant) |
| Field family and index mapping | /docs/elk/…component-template.json |
EndpointLogFieldTest in limesium-common: locks the one EndpointLogField enum both twins inline against the template |
| Message text and meter names | the servlet module's emitter and metrics | TwinContractTest in both modules |
The reactive build pulls the two shared files from the sibling checkout as test resources (declared
in its pom.xml), so a missing sibling 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 stack works unchanged for the other.
2. Shared architecture¶
2.1 Shared components¶
Both modules are built from the same five layers — auto-configuration, the stack's filter lifecycle,
state and emission, capture, cross-cutting — and from the same set of named components. Some of them are
one byte-identical class inlined into both jars from limesium-common
(§6.4); the others are per-stack twins with the same
name and contract whose code genuinely differs:
| Component | Responsibility | Shared how |
|---|---|---|
RequestLoggingProperties |
The endpoint-logging.* binding, validated in init |
per-stack twin; the reactive one adds variant |
HeaderLogProperties |
One header section — includes / excludes / masked / unmasked — with the selection and the masking fingerprint (§4.2) |
byte-identical (limesium-common) |
ExchangeLogEmitter |
Resolves level, outcome and cause for the stack and emits the completion event; the arrival line, the message texts, the header rendering and the body measurements are the shared ExchangeLine |
per-stack twin around a byte-identical core (ExchangeLine, limesium-common) |
EndpointLogField |
The wire names and the exact JVM type of each structured field; a wrongly typed value drops the field with a warning, never the event | byte-identical (limesium-common), locked against the one template |
EndpointLoggingMetrics |
The six meters — the fixed-tag meters pre-registered, the body meters created lazily per tag — with per-meter fallback to a private registry on registration conflict | byte-identical (limesium-common), parameterized with the stack's third outcome (timeout / cancelled) |
BoundedBodyCapture |
The bounded capture target; count-only mode with limit 0; the request-side read state (BodyReadState) |
per-stack twin (two concurrency designs, §6.1) |
Traceparent |
Strict W3C traceparent parsing to (traceId, parentSpanId) (§5.6) |
byte-identical |
MdcKeys / TraceMdcKeys / MdcScope |
The MDC key names; the scope that puts identity (and, for the emission, the trace keys) into the MDC and restores the previous values on close | byte-identical |
NanoTimeSource / CorrelationIdGenerator / HeaderValueMasker |
Injectable time, id and header masking (§2.5) | byte-identical |
reportQuietly / reportFailOpen |
reportQuietly guards the diagnostics channel (counter + internal log) of every catch block; reportFailOpen is the one report every catch shares - the stage counter plus one line on the module's own logger, inside that guard |
byte-identical |
The per-stack component overviews — the filter classes, the async and variant machinery, the capture wrappers and decorators — are §2.1 of the servlet guide and of the reactive guide.
2.2 Exchange identity¶
At filter entry each module resolves one request id for the exchange (ADR-0002), in this order:
| The request carries | Request id | Response echo | correlation.id{source=…} |
|---|---|---|---|
a conformant W3C traceparent header |
its trace id | none — the wire stays untouched | trace |
no conformant traceparent, but the configured correlation header (X-Correlation-Id by default) with an acceptable value — 1 to 128 visible-ASCII characters (CorrelationHeaderValue) |
the header value | echoed on the response | header |
| neither — a correlation header that is absent, blank, longer than 128 characters or not visible ASCII counts as absent | a generated id (CorrelationIdGenerator, ADR-0004) |
echoed on the response | generated |
The echo is set once at filter entry — downstream code that sets the header itself decides what the
client finally sees; event and MDC keep the id resolved at entry. The id is the endpoint_request_id
MDC entry (§5.2) and the inline [endpoint_request_id=…] in the message; the generator
is never consulted for a traced exchange. Which traceparent counts as conformant is
§5.6.
2.3 The body tee: capture mirrors consumption¶
Bodies are never pre-read, buffered or replayed. Each module installs a passive tee — servlet
stream/reader/writer wrappers on the one stack, DataBuffer decorators on the other — whose target is
BoundedBodyCapture: a ByteArrayOutputStream of at most max-body-bytes and a total byte counter.
With limit 0 it runs in count-only mode for the body-size meters: nothing is buffered, every byte
is counted. The tee exists only when a body is logged (in any mode — on-failure needs the bytes before
the outcome is known, §4.3) or measured; without either, the
chain receives the original request and response.
The capture mirrors consumption, not transmission. The filter sees exactly the bytes the application
actually reads or writes — no more. A request body the handler never consumes (a @PostMapping without
@RequestBody, a request rejected before the controller, an early abort) is logged as absent and records
no size sample, even though the client 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. The same holds
on the response side: what the application writes through the tee is what the log shows. 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 client
sent but the application ignored from one that was never sent; the counter endpoint.request.body.read
(§5.4) exists for exactly that distinction.
The mechanics — what is wrapped, what bypasses the tee, how the capture is made visible to the emitting thread — are the servlet guide's §2.6 and the reactive guide's §2.5.
2.4 Fail-open contract¶
A logging component must never fail the request it describes. Both modules enforce that at every
boundary where they call host-provided code (MDC adapter, appenders, MeterRegistry, the container's or
server's request/response facade). Every failure is swallowed, counted on endpoint.logging.failopen
by stage, and reported on the module's own logger:
| Stage | Meaning | What happens | Counted as |
|---|---|---|---|
| wiring | bookkeeping around one exchange failed: constructing the captures, resolving the identity, opening or closing an MDC scope, reading the handler template, recording a sample or a counter | the filter degrades to a plain pass-through for this request, or the one piece of bookkeeping is lost — the event usually still follows | failopen{stage=wiring} |
| arrival | logRequestStart, including its level gate |
the arrival line is dropped | failopen{stage=arrival} |
| emission | logExchange — everything after the exactly-once gate |
the exchange event is lost | failopen{stage=emission} |
| registration | EndpointLoggingMetrics.registerOrFallback |
the conflicting meter lives in a private registry, warned once per name (§5.4) | — |
Every catch block reports through reportFailOpen - the stage counter plus one line on the module's own
logger - inside reportQuietly, which swallows a failure of the diagnostics channel
itself (a throwing Counter, a throwing appender that also covers the internal logger) — there is
nothing left to report to. InterruptedException is caught separately and the interrupt flag is
restored before the failure is recorded.
Failures of the logging are reported on the module's own loggers under
eu.inqudium.limesium.servlet.logging resp. eu.inqudium.limesium.reactive.logging (the filter, the
emitter, the metrics), never on the exchange logger, so the exchange stream stays parseable. The exact
boundaries per stack — which method, which stage — are the
servlet guide's §2.8
and the
reactive guide's §2.7.
Security note. Fail-open is the inverse of what an audit log needs: a host-side fault silently
removes the exchange from the log instead of failing the request. The exchange log is therefore an
observability feature with no completeness guarantee; a regulatory audit trail must come from a
fail-closed component. The compensating controls are endpoint.logging.failopen and the
exchanges.open gauge (§5.5) — alert on them.
2.5 Injectable collaborators¶
Time and randomness are injected, not ambient:
NanoTimeSource— monotonic nanoseconds forendpoint_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 requests without a correlation header;CorrelationIdGenerator.DEFAULT(a counting generator: random per-instance base-36 prefix plus counter, 21 characters — ADR-0004) by default. Never consulted for a traced exchange (ADR-0002: thetraceparenttrace id is the request id).HeaderValueMasker— how a header listed in amaskedsection renders on the line;DEFAULTis the stablelength:hashfingerprint (§6.3). 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.
All three are fun interfaces, all three are @ConditionalOnMissingBean beans
(§3.4), and all are what the modules' tests drive from an
AtomicLong / a fixed string / a lambda without any mocking library.
3. Using it in a foreign project¶
3.1 Prerequisites¶
| Requirement | Notes |
|---|---|
| Spring Boot 4.x web application of the module's type | each module is conditional on its web application type (@ConditionalOnWebApplication(type = SERVLET) resp. REACTIVE) and inert in the other. An application may carry both jars: the matching one activates, the other stays inert — keep them at the same version, both inline the same shared classes |
| Java 21, Kotlin stdlib on the runtime classpath | the modules are written in Kotlin; a Java host only needs kotlin-stdlib, which the jar pulls transitively |
| SLF4J 2.x binding (Logback by default in Boot) | the modules use the fluent LoggingEventBuilder API (addKeyValue) |
| Micrometer core | present via the web starter; an actuator MeterRegistry is optional |
Both modules are libraries, not starters: they declare spring-boot-autoconfigure, slf4j-api,
spring-web, micrometer-core and kotlin-stdlib — plus the provided Jakarta Servlet API on the
servlet stack and reactor-core on the reactive stack — and nothing else: no logging backend, no YAML,
no container or server are forced onto the host.
What the stack adds — supported containers and the Servlet API on the one side, the optional coroutine and context-propagation libraries on the other — is §3.1 of the servlet guide and of the reactive guide.
3.2 Adding the dependency¶
Pick the artifact for the host's stack:
<dependency>
<groupId>eu.inqudium</groupId>
<artifactId>limesium-servlet-logging</artifactId> <!-- or limesium-reactive-logging -->
<version><!-- current release: see the badges below --></version>
</dependency>
There is no BOM; the version is declared on the dependency itself. The current release is shown live by
the Maven Central badges:
That is all: the auto-configuration wires the filter (§3.3), every exchange is logged on
the endpoint-http-exchange logger at INFO, the request id comes from the traceparent trace id
(traceless exchanges read/echo X-Correlation-Id instead — ADR-0002), and the six meters are registered
in the host's MeterRegistry if one exists.
To remove the module again without touching the classpath:
endpoint-logging:
enabled: false
3.3 Wiring¶
Automatic wiring is the module's auto-configuration, listed in the auto-configuration imports
resource and conditional on two things: the web application type of its stack, and
endpoint-logging.enabled (default true; false removes the filter and every default bean together).
There is nothing for the host to inject and nothing to build: every exchange the container or server
hands to the application passes the filter, and path activation (include-path-patterns,
exclude-path-prefixes) is evaluated inside the filter — never through a registration's URL mapping —
so its semantics are byte-identical on both stacks (§4.4). Both filters order
themselves at Ordered.HIGHEST_PRECEDENCE + 10: early enough that the traceless correlation echo is set
before anything else runs and everything after it sees the exchange identity.
What the wiring physically consists of is the stack's business — a filter registration plus the
completion listener that is the emission point on the servlet stack, one WebFilter bean that WebFlux
collects on the reactive stack — and so is manual wiring, needed when the container or handler is
assembled without Boot's web auto-configuration or outside a Spring context:
| Automatic wiring | Manual wiring | |
|---|---|---|
| servlet | §3.2 | §3.3 |
| reactive | §3.2 | §3.3 |
Rules that hold for a hand-wired filter on either stack:
- One filter per
MeterRegistry. The meters are identified by name, so all filters on one registry share one metrics owner and theendpoint.logging.exchanges.opengauge reports the total across them (§6.2). A second instance buys nothing. - Activation is not the host's business. Path activation is evaluated inside the filter, so a manually wired filter applies the same rules as an automatically wired one; there is no need to map or add it selectively.
- Ordering is the host's business. The automatic wiring guarantees the early position; a manual registration lands where the host puts it. Keep it early — before everything that logs.
- The overridable beans stay overridable. Every constructor takes
(RequestLoggingProperties, NanoTimeSource, CorrelationIdGenerator, MeterRegistry)plus an optional trailingHeaderValueMasker(the built-in fingerprint when omitted), and every default is public:RequestLoggingProperties()(or acopy(...)with the fields to change),NanoTimeSource.SYSTEM,CorrelationIdGenerator.DEFAULT, aSimpleMeterRegistry()or the registry the surrounding code owns. - Inside a Boot context with the auto-configuration switched off, the host binds the properties
class itself (
@EnableConfigurationProperties(RequestLoggingProperties::class)), because that annotation lives on the auto-configuration that is now gone.
Everything else is unchanged by the way the filter was wired: emission point, outcomes, meters, header
sections, body capture and the fail-open contract behave exactly as under the automatic wiring — the
filter does not know how it got into the chain. The wiring itself is fail-open like everything else: a
failure while wiring one exchange degrades that request to a pass-through with a stage=wiring count
(§2.4).
3.4 Overriding the collaborator beans¶
Every default is @ConditionalOnMissingBean:
@Configuration(proxyBeanMethods = false)
class EndpointLoggingCustomisation {
/** Deterministic ids in a test profile, or a different id format. */
@Bean
fun correlationIdGenerator(): CorrelationIdGenerator =
CorrelationIdGenerator { "req-" + ULID.random() }
/** 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() }
}
Replacing the filter bean itself is stack-specific — on the servlet stack the auto-configured registration and completion listener still wrap the host's filter (servlet guide §3.5), on the reactive stack a host bean of either variant makes both auto-configurations back off (reactive guide §3.6). Keep in mind the one-instance-per-registry limitation of the gauge (§6.2).
3.5 Logging backend and structured output¶
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) |
endpoint_outcome, endpoint_duration_ms, endpoint_url_path, endpoint_request_body |
| The identity and trace context | MDC entries, set by the emission scope (and, during the exchange, as the stack provides them) | endpoint_request_id, endpoint_method, endpoint_route, traceId, parentSpanId (from the traceparent header) |
A plain %msg pattern shows neither — only the message, which repeats the gist inline
(… -> 200 [endpoint_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)¶
Logback ≥ 1.3 renders the key-value pairs with the %kvp conversion word and the MDC with %mdc
(all entries) or %X{key} (one entry):
<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] endpoint-http-exchange - Endpoint http exchange GET /api/things/42 -> 200 [endpoint_request_id=4bf9… traceId=4bf9… parentSpanId=00f0…] endpoint_outcome=success endpoint_duration_ms=17 endpoint_request_method=GET endpoint_url_path=/api/things/42 endpoint_url_template=/api/things/{id} endpoint_response_status_code=200 [endpoint_method=GET, endpoint_request_id=4bf9…, endpoint_route=/api/things/42, traceId=4bf9…, parentSpanId=00f0…]
%kvpquotes values with double quotes by default;%kvp{NONE}leaves them bare,%kvp{SINGLE}uses single quotes.%X{endpoint_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 exchanges. A conditional prefix ("traceId=only when present") is not expressible in a pattern — use%mdcor a structured encoder.- In Spring Boot the same pattern goes into
logging.pattern.consolewithout any XML. - This is both modules' own test configuration (
src/test/resources/logback-test.xml), so a test run shows the complete event. - Text output renders values raw. The logged path and query are percent-encoded as sent, but bodies (opt-in) may contain line breaks — see the security audit's CWE-117 notes before pointing a text appender at a log 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 ("kvpList":[{"endpoint_outcome":"success"},…]) and the MDC nested under
"mdc":{…}. Correct and safe, yet awkward to map onto the flat endpoint_* fields of the index
template; suitable for local JSON inspection, not the recommended shape for an index.
Option 3 — Spring Boot structured logging (JSON, flat, typed — recommended for an index)¶
Boot ≥ 3.4 ships StructuredLogEncoder, configured without XML:
logging:
structured:
format:
console: ecs # or logstash, gelf
level:
endpoint-http-exchange: INFO
eu.inqudium.limesium.servlet.logging: WARN # or eu.inqudium.limesium.reactive.logging
Key-value pairs and MDC entries become flat top-level fields, and values keep their JVM type —
endpoint_duration_ms is a number, endpoint_response_status_code a number, which is what the
type assertion in EndpointLogField guarantees on the producing side:
{"@timestamp":"2026-08-23T13:54:58.534Z","log.level":"INFO","message":"Endpoint http exchange GET /api/things/42 -> 200 [endpoint_request_id=0f7c…]","endpoint_outcome":"success","endpoint_duration_ms":17,"endpoint_request_method":"GET","endpoint_url_path":"/api/things/42","endpoint_url_template":"/api/things/{id}","endpoint_response_status_code":200,"endpoint_request_id":"0f7c…","endpoint_method":"GET","endpoint_route":"/api/things/42","ecs.version":"8.11"}
This is the shape the component template in §3.6 is written for. The same encoder is
available in XML as <encoder class="org.springframework.boot.logging.logback.StructuredLogEncoder"><format>ecs</format></encoder>,
and logging.structured.json.include / exclude / rename control the field selection (e.g. to drop
endpoint_route, which duplicates endpoint_url_path). Where MDC entries land in the document — flat,
nested, renamed — is this encoder configuration's decision, which is why the index template maps only
the key-value family and leaves the MDC keys to the host.
A fourth option, logstash-logback-encoder's LogstashEncoder, also renders the key-value pairs flat
(<includeKeyValuePairs>), but needs an additional dependency — sensible only if the host uses it already.
| 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.limesium.servlet.logging resp.
eu.inqudium.limesium.reactive.logging — at WARN or lower: it carries the WARN breadcrumb on a thrown
chain and the module's own failure reports.
3.6 Index mapping (ELK)¶
The thirteen endpoint_* fields have a ready-made Elasticsearch component template in
/docs/elk/:
curl -X PUT "$ES/_component_template/limesium-servlet-logging-fields" \
-H 'Content-Type: application/json' \
--data-binary @docs/elk/limesium-servlet-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.
3.7 Verifying the integration¶
- Start the application and call any endpoint:
curl -i -H 'X-Correlation-Id: demo-1' http://localhost:8080/api/things/42
Expect X-Correlation-Id: demo-1 on the response and one endpoint-http-exchange line with
endpoint_request_id=demo-1. With a traceparent header instead
(curl -i -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' …), expect
no X-Correlation-Id response header and endpoint_request_id=4bf92f… plus
traceId=… parentSpanId=… on the line (ADR-0002).
-
Log something inside the handler and confirm
endpoint_request_idis on that line too — on the servlet stack always; on the reactive stack in the coroutine variant, or in the Reactor variant once handler-side MDC is enabled (reactive guide §3.5). -
Throw from a handler and confirm: an immediate WARN breadcrumb on the filter's own logger (
eu.inqudium.limesium.servlet.logging.RequestLoggingFilterresp.eu.inqudium.limesium.reactive.logging.RequestLoggingWebFilter), then the exchange line with the rendered500,endpoint_outcome=failureat ERROR with the cause attached. -
Check the meters (with actuator):
curl -s localhost:8080/actuator/metrics/endpoint.logging.events
curl -s localhost:8080/actuator/metrics/endpoint.logging.exchanges.open
events should equal the number of logged lines; exchanges.open should be 0 when idle.
4. Configuration¶
All properties live under endpoint-logging.*, and the namespace is identical on both stacks — key
for key and default for default; the only reactive-only addition is variant. The complete, commented
reference with every default is /docs/endpoint-logging-reference.yml
(the reactive module ships a copy that adds variant); EndpointLoggingReferenceConfigTest binds it
against RequestLoggingProperties in both modules and fails the build on any drift — every key must
exist, every value must be the built-in default.
What a property means is the same on both stacks. Where the stack adds a nuance — what exactly an excluded request skips, against which path the patterns match, what the slow threshold measures — §4 of the servlet guide and of the reactive guide has it.
4.1 Property reference¶
| Property | Type | Default | Meaning |
|---|---|---|---|
enabled |
boolean | true |
Master switch. false makes the auto-configuration back off — no filter, no registrations, no default beans. A context-start decision, not a runtime toggle. |
variant |
auto | reactor | coroutine |
auto |
Reactive-only — selects the WebFlux filter variant; the servlet module has no such key. auto = coroutine variant when kotlinx-coroutines-reactor + kotlinx-coroutines-slf4j are present, Reactor otherwise; reactor forces the Reactor variant; coroutine requires the libraries and fails startup without them (reactive guide §3.4). |
logger-name |
string | endpoint-http-exchange |
Logger of the arrival line and the exchange event. Its level is the runtime volume control (§4.5). |
correlation-id-header |
string (RFC 9110 token) | X-Correlation-Id |
Header the correlation id is read from on traceless exchanges (no conformant traceparent — ADR-0002); absent, blank, longer than 128 characters or not visible ASCII means generated (the value is echoed and written into every log line of the exchange, so its length and shape are bounded, not peer-controlled). Only such an exchange gets the echo, set once at filter entry — downstream code that sets the header itself decides what the client finally sees; event and MDC keep the id resolved at entry. A traced exchange takes its request id from the traceparent trace id, ignores this header and echoes nothing. |
include-query-string |
boolean | true |
Log the query string as its own field endpoint_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 chain runs, at INFO, with the identity in the MDC. Carries no outcome/status/duration. |
include-path-patterns |
list of PathPattern |
[] |
Endpoints the filter is active for at all; empty = every endpoint. Parsed once at startup; an invalid pattern fails the context. |
exclude-path-prefixes |
list of strings | [] |
Request-path prefixes the filter skips entirely — no event, no MDC, no correlation echo, no gauge movement. Prefix match against the decoded request path. An exclude always wins over an include. |
slow-request-threshold |
duration | 5s |
At/above this duration an INFO exchange escalates to WARN and is flagged endpoint_slow: true; the outcome stays success. Measured from filter entry to the emission point. Must be ≥ 1 ms. |
request-headers.includes / .excludes / .masked / .unmasked |
lists of header names | see §4.2 | The request-header section. |
response-headers.includes / .excludes / .masked / .unmasked |
lists of header names | see §4.2 | The response-header section. |
log-request-body |
never | on-failure | always |
never |
Tee the request body into endpoint_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, §4.3). |
log-response-body |
never | on-failure | always |
never |
Tee the response body into endpoint_response_body, 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 endpoint.request.body.size; independent of log-request-body. |
measure-response-body-size |
boolean | false |
Record endpoint.response.body.size; independent of log-response-body. |
max-body-bytes |
int > 0 | 16384 |
Capture limit per body. Bounds memory, not the exchange: bytes beyond it still flow; the logged value is truncated with a note of the total size. |
masking-key |
string (bound to MaskingKey, whose toString redacts it) |
(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. |
4.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 | Default | Semantics |
|---|---|---|
includes |
[] |
Names to log. Empty logs nothing (the safe default). The entry * logs every header the message carries, deduplicated case-insensitively. |
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. 18:930bbdc51b6aed5c (a pseudonym, not anonymisation: equal values stay recognisable as equal; key it with masking-key to stop guess confirmation). The default masks every logged header (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 filter entry; response headers at emission, so they reflect what the chain and the error rendering set.
4.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 | — | chain gets the original request/response, zero overhead |
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 endpoint_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 exchange; what is nearly always wanted is bodies for the exchanges that
went wrong — failure, and the stack's own disposition (timeout on the servlet stack, cancelled on
the reactive stack) — which cuts the volume by orders of magnitude and hits exactly the lines a body is
wanted for. The emitter decides 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 (§5.3) by one status
class: a 4xx response keeps its success outcome — the application 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 exchange stays success and logs no bodies.
Rules that hold for every combination on both stacks:
- The tee is passive: bytes are counted and (up to the limit) copied as they flow; nothing is pre-read, replayed or withheld. Streaming behaviour is untouched (§2.3).
- An unread request body is logged as absent; no size sample is recorded.
- 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 declared request/response encoding, UTF-8 when absent or unparsable.
measure-*records what actually flowed, exact beyondmax-body-bytes.measure-request-body-sizeadditionally recordsendpoint.request.body.read— whether the application consumed the body completely, partially, or not at all (§5.4).
What bypasses the tee on each stack (container error rendering, buffer resets, raw async cycles,
zero-copy responses, and — on both stacks — bodies the framework parses itself: a form POST read
through @RequestParam/getParameter* on the servlet stack or @ModelAttribute/getFormData() on the
reactive stack, a multipart request through getParts()/getMultipartData(); such an exchange logs no
request body and counts as unread) is §4.3 of the
servlet guide
and of the
reactive guide.
4.4 Path activation¶
active(path) = (include-path-patterns is empty OR any pattern matches path)
AND no exclude-path-prefix is a prefix of path
An inactive request passes through without any trace: no correlation echo, no MDC, no event, no gauge movement, no counters. Typical use:
endpoint-logging:
include-path-patterns:
- /api/**
exclude-path-prefixes:
- /actuator/health
- /actuator/prometheus
include-path-patterns uses Spring's PathPattern syntax (/api/**, /api/{*rest},
/files/{id}.pdf); exclude-path-prefixes is a prefix match. Both see the request target the way the
stack's router does — the path within the application (a configured context path resp. base path is
stripped first, exactly as in the handler mapping), parsed into segments that decode for matching
and drop path parameters — so /api/** matches /app/api/things under context path /app,
/%61pi/things is included by /api/** and /%61ctuator/health is excluded by /actuator/health,
exactly as the router would serve them. The logged endpoint_url_path stays raw and keeps the context
path.
4.5 Logger levels¶
Severity and semantic are decoupled: the level only decides how loud — and whether — a line is emitted;
endpoint_outcome carries the disposition (§5.3). The level of the
logger-name logger therefore acts as the runtime volume control:
endpoint-http-exchange level |
Emitted |
|---|---|
INFO |
every exchange |
WARN |
failures the application handled (5xx), the stack's own disposition (a container async timeout on the servlet stack, a cancelled subscription on the reactive stack), slow exchanges — and thrown chains |
ERROR |
only exchanges whose chain threw or signalled an error (on the servlet stack also an errored async cycle) |
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 endpoint.logging.events, which by definition counts emitted events only.
4.6 Validation at startup¶
RequestLoggingProperties.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 to every traceless response; a non-token would be rejected per request by a strict container or server adapter and silently turn the filter into an unlogged pass-through); 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;
*in anexcludesor anunmaskedlist;- an unparsable
include-path-patternsentry (parsed once at filter construction).
4.7 Example configurations¶
Minimal production profile — everything logged, health probes excluded, slow threshold tightened:
endpoint-logging:
exclude-path-prefixes:
- /actuator/health
slow-request-threshold: 2s
logging:
level:
endpoint-http-exchange: INFO
eu.inqudium.limesium.servlet.logging: WARN # or eu.inqudium.limesium.reactive.logging
Diagnostics profile — headers with masked credentials, request bodies, arrival lines:
endpoint-logging:
log-request-start: true
log-request-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, Set-Cookie]
unmasked: [Content-Type, Content-Length] # Set-Cookie stays a fingerprint
Production profile with bodies — bodies only for the exchanges that went wrong; the request body is
captured up to max-body-bytes per exchange and dropped on success:
endpoint-logging:
log-request-body: on-failure
log-response-body: on-failure
max-body-bytes: 4096
Metrics without log volume — body sizes measured, only failures logged:
endpoint-logging:
measure-request-body-size: true
measure-response-body-size: true
logging:
level:
endpoint-http-exchange: WARN
API-only scope with a custom correlation header:
endpoint-logging:
correlation-id-header: X-Request-Id
include-path-patterns:
- /api/**
include-query-string: false
A reactive host that wants handler-side MDC in the Reactor variant adds the propagation setup of the reactive guide's §4.6.
5. Metrics and observation¶
5.1 Log fields¶
The structured fields of the completion event (the arrival line carries only method, path, query and
request headers). The index types are those of the shipped component template; EndpointLogFieldTest
keeps each module's enum in lockstep with it.
| Field | Type | Index | doc_values | When present | Notes |
|---|---|---|---|---|---|
endpoint_outcome |
keyword | yes | on | always | success / failure plus the stack's own disposition — timeout (servlet) or cancelled (reactive); the field dashboards split by; decoupled from the level |
endpoint_duration_ms |
long | yes | on | always | from the injected monotonic source; measured from filter entry to the emission point |
endpoint_request_method |
keyword | yes | on | always | |
endpoint_response_status_code |
short | yes | on | always — except a reactive cancellation that never committed | the final status at emission — a numeric label, never summed |
endpoint_url_path |
keyword | yes | off | always | the raw request path as sent (percent-encoding intact), ids and all — filter exactly, never group |
endpoint_url_template |
keyword | yes | on | when the framework (Spring MVC / WebFlux) recorded a handler pattern | the aggregation half of the path pair, e.g. /api/things/{id} |
endpoint_url_query |
keyword | yes | off | when the request had one and include-query-string is on |
raw, as sent |
endpoint_slow |
boolean | yes | on | only when the threshold was reached | absence means fast |
endpoint_async |
boolean | yes | on | servlet only, always there; never emitted by the reactive module | true when the chain returned with async processing started; the reactive enum keeps the constant so both map the same template |
endpoint_request_headers |
keyword | no | off | when selected headers are present | display only, rendered [Name:"value", …] |
endpoint_response_headers |
keyword | no | off | when selected headers are present | display only |
endpoint_request_body |
keyword | no | off | when log-request-body admits the outcome and bytes flowed |
display only, bounded |
endpoint_response_body |
keyword | no | off | when log-response-body admits the outcome and bytes flowed |
display only, bounded |
Each field asserts the exact JVM type of its value (EndpointLogField.format): a wrongly typed value
drops that field with a warning on the module's EndpointLogField logger, never the event.
The throwable of a failed chain is attached to the event as its cause (setCause), so a structured
encoder renders the stack trace alongside the fields. Which further throwables a stack attaches (the
servlet async callbacks' ones) is the module guide's §5.1.
5.2 MDC keys¶
| Key | Value | Scope |
|---|---|---|
endpoint_request_id |
the request id: the traceparent trace id, or the accepted/generated correlation id (ADR-0002) — always set |
the emission, and during the exchange as the stack provides it |
endpoint_method |
the HTTP method | same |
endpoint_route |
the request path (the template is not known at filter entry) | same |
traceId |
the trace id parsed from the traceparent header |
emission only |
parentSpanId |
the caller's span id parsed from the traceparent header — never published as spanId |
emission only |
MdcScope restores the previous value of every key on close (worker threads are pooled; an outer filter
may own the same keys), 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.
Where the three identity keys are visible during the exchange — the whole filter chain and the MVC
async worker on the servlet stack; the Reactor context, opt-in handler MDC or MDCContext on the
reactive stack — is §5.2 of the
servlet guide
and of the
reactive guide.
5.3 Levels and outcomes¶
Resolved in this order in ExchangeLogEmitter:
| Condition | Level | endpoint_outcome |
Cause attached |
|---|---|---|---|
| the chain threw or signalled an error | ERROR |
failure |
the exception |
the stack's own disposition — servlet: async cycle timed out (WARN, timeout) or errored (ERROR, failure); reactive: subscription cancelled (WARN, cancelled) |
see the module guide's §5.3 | ||
| status ≥ 500 without any of the above (the application handled it) | WARN |
failure |
— |
| otherwise | INFO |
success |
— |
… and the duration reached slow-request-threshold |
INFO → WARN |
unchanged, plus endpoint_slow: true |
— |
Slowness raises severity; it never turns a completed exchange into a failure.
5.4 Meters¶
Six meters, all consumed from the host's MeterRegistry (an ObjectProvider; without one a
private SimpleMeterRegistry absorbs the values). 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.server.requests and the log fields. The names are
identical on both stacks and pinned by TwinContractTest.
| Meter | Type | Tags | Meaning |
|---|---|---|---|
endpoint.logging.failopen |
counter | stage = emission | arrival | wiring |
Logging failures the fail-open path swallowed (§2.4). emission: an exchange event was lost. arrival: a start line was lost. wiring: bookkeeping failed (pass-through degradation, a lost MDC scope, 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. |
endpoint.logging.events |
counter | outcome = success | failure | the stack's own disposition (timeout / cancelled) |
Exchange events actually emitted on the exchange logger — after the level gate, arrival lines excluded. The reconciliation ground truth against the log index. |
endpoint.logging.exchanges.open |
gauge | — | Exchanges between filter entry and the exactly-once completion (request destruction on the servlet stack, the terminal signal or commit on the reactive stack). Hovers near the active-request count in health. |
endpoint.logging.correlation.id |
counter | source = trace | header | generated |
Origin of each exchange's request id (§2.2); the meter name predates ADR-0002 and stays stable. |
endpoint.request.body.read |
counter | uri = handler pattern, UNKNOWN without one; state = unread | partial | complete |
How far the application consumed the request body, opt-in via measure-request-body-size. Recorded once per exchange whenever the measuring tee exists — including bodyless requests the application never touched, which is the unread share the counter exists to show. partial = consumption started but the end of the body was never observed (what that looks like per stack is the module guide's §5.4). Bodies the framework parses itself (form and multipart requests read through the parameter/form-data API) never pass the tee and always count as unread — read the share per uri with that in mind. Created lazily per uri/state on first use, like the size summaries. |
endpoint.request.body.size / endpoint.response.body.size |
distribution summary, base unit bytes |
uri = handler pattern, UNKNOWN without one |
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. Created lazily per uri on first use. |
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 the module's EndpointLoggingMetrics logger: the module keeps working and that meter is
simply not exported.
5.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 (the emission point never fired — nothing throws, so no fail-open count)? | exchanges.open baseline grows monotonically instead of returning towards 0 |
| Is the log pipeline (appender, broker, index) losing events? | sum(endpoint.logging.events) over a window ≠ count of indexed endpoint-http-exchange documents for the same window |
Did the upstream stop propagating identity (traceparent or correlation ids)? |
the generated share of correlation.id rises |
| Are async cycles timing out (servlet) / are clients disconnecting (reactive)? | events{outcome="timeout"} resp. events{outcome="cancelled"} |
| Is an endpoint ignoring or abandoning the payload it is handed? | the unread or partial share of request.body.read{uri=...} rises — the logged body and the size sample cannot show this, both describe only what was consumed. Form and multipart endpoints sit at unread by construction (the framework parses those bodies beside the tee), so judge the share per uri, not globally |
| Are payloads growing beyond what the log captures? | body.size percentiles vs. max-body-bytes |
A suggested alert set:
# lost exchange events (hard failure)
increase(endpoint_logging_failopen_total{stage="emission"}[5m]) > 0
# silently stuck exchanges (liveness) - tune the bound to the service's concurrency
min_over_time(endpoint_logging_exchanges_open[15m]) > 50
# correlation contract regression
sum(rate(endpoint_logging_correlation_id_total{source="generated"}[10m]))
/ sum(rate(endpoint_logging_correlation_id_total[10m])) > 0.2
What "the emission point never fired" means on each stack — requestDestroyed not firing, a commit
that never happens — is the module guide's §5.5.
5.6 Trace correlation¶
The trace context comes from the incoming W3C traceparent header, parsed by the module at filter entry
with the full W3C validation (ADR-0002; the parser is the shared Traceparent from limesium-common,
inlined into both jars — ADR-0003):
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
└──────── traceId ───────────────┘ └─ parentSpanId ─┘
traceIdis the trace the server span runs under, published under Boot's logging-correlation keytraceId, so the log-to-trace join holds.- The header's parent-id is the caller's span. It is published as
parentSpanId— never asspanId, where it would read as the local span and, with a bridge active, overwrite the real one. - 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 exchange counts as traceless for the identity decision. The conformance is pinned bytraceparent/conformance.txtinlimesium-common. - Since ADR-0002 the trace id also is the exchange's
endpoint_request_id, and a traced exchange gets noX-Correlation-Idecho — the identity decision and the trace fields share the one strict parse. - The ids ride the MDC only, never the key-values; the emission
MdcScoperestores the parsed pair around the event — as MDC fields for structured encoders, and inline in the message (… traceId=… parentSpanId=…) for plain-text appenders.
The emission scope owns the trace keys, a bridge's spanId included: a parsed id is installed, an
unparsed one is removed for the scope's lifetime, so a stale id on a pooled emission thread can never
join the event to a foreign trace or span. Without a (valid) traceparent, nothing is decorated — a
trace the bridge mints locally is deliberately not joined; such an exchange carries a generated request
id instead. Both modules pin this against a real Brave bridge running beside them; what each stack's
threads carry at filter time and at emission is the module guide's §5.6.
6. Shared characteristics¶
6.1 Differences between the stacks¶
Everything not listed here behaves identically in both modules. Each row links the section of the module guide that explains the stack's side.
| Concern | Servlet module | Reactive module |
|---|---|---|
| Disposition vocabulary | success / failure / timeout — the container's async timeout |
success / failure / cancelled — a client disconnect, the reactive reality; there is no container async timeout in WebFlux (§6.1) |
endpoint_async |
emitted, always | never emitted — everything is asynchronous, the flag would carry no information |
endpoint_response_status_code |
always present | absent for a never-committed cancellation |
| Emission point | requestDestroyed, after the container's error dispatch and after async completion (§2.4) |
the terminal signal; on an error with an uncommitted response deferred to the beforeCommit callback (§2.4) |
| Never-completing error rendering | n/a — destruction always fires | the exchange stays open on the gauge instead of logging a wrong status |
endpoint_duration_ms |
request occupancy including error rendering and async waiting (§6.1) | until the terminal signal or the commit |
| Chain-wide MDC | thread-local, for the whole chain, plus the Spring MVC async worker (§2.7) | Reactor context + opt-in accessors (Reactor variant), or MDCContext in the coroutine variant (§2.6) |
| Trace context | parsed at filter entry, restored by the emission scope around the destruction callback — a pooled thread without per-request state; the chain scope leaves the trace keys to the bridge | the same parsing and keys; restored by the emission scope at the terminal signal |
| Body tee | stream/reader and stream/writer wrappers; reset(), resetBuffer() and sendError clear the capture (§6.3) |
DataBuffer map-tee; no reset analog — emitted buffers are on their way to the client |
| Body capture concurrency | single writer, late reader; a volatile total as the happens-before edge | lock-guarded, frozen at emission — late chunks after cancellation (§6.4) |
| Variant selection | one filter | endpoint-logging.variant (auto / reactor / coroutine, §3.4) |
| Handler template attribute | Spring MVC's HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE |
WebFlux's HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE — each mirrored as a constant so the module does not depend on the MVC/WebFlux jar; pinned by HandlerMappingAttributeTest on both sides |
| Engine matrix | Tomcat, Jetty, and Undertow as unsupported territory — one integration suite each (container guide) | Netty only — WebFlux has no per-container WAR story |
6.2 One metrics instance per registry¶
Micrometer deduplicates meters by id. A second EndpointLoggingMetrics instance against the same
registry shares the counters (increments merge) but not the gauge: the second gauge registration
is silently ignored and that instance's open-exchange movements become invisible. The auto-configuration
creates exactly one filter and therefore one instance; a host wiring additional filter instances against
one registry inherits this limitation knowingly.
6.3 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 and modules (both stacks use the same scheme, and so does the
outbound sibling Legatium), 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: endpoint-logging.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
(§2.5): a host pins its own (a fixed *** for no correlation at all)
once, and both stacks mask with it. The contract a replacement must keep: never return the plaintext.
6.4 Shared code: limesium-common, inlined by Shade¶
The BYTE-identical part of the twins' shared layer lives in the limesium-common module
(ADR-0003): the Traceparent parser (with its
tests and fuzz target), HeaderLogProperties (selection and masking fingerprint, with its unit test and
fuzz target — ADR-0003 amendment 2026-08-31), NanoTimeSource, CorrelationIdGenerator,
HeaderValueMasker, CorrelationHeaderValue, MaskingKey, the fail-open helpers, the MDC keys and scope, and — since
the amendment of 2026-09-05 — the field enum EndpointLogField, the meters EndpointLoggingMetrics
(parameterized with the stack's third outcome) and ExchangeLine, the stack-neutral core of the
emitters (message texts, header rendering, the arrival line, the body measurements) over the two small
interfaces LoggedExchange and MeasuredBody that both twins' Exchange and BoundedBodyCapture
implement. The Maven Shade plugin inlines those
classes into each module's jar at package time, the dependency-reduced POM drops the dependency, and
limesium-common is never published — consumers keep adding exactly one artifact, and the shared
classes stay internal (-Xfriend-paths).
Everything whose twin copies genuinely differ stays deliberately duplicated, per the original
architecture-review decision: the field enum and metrics (per-stack outcome vocabulary and meter
descriptions), the emitters and exchanges, the properties (variant is reactive-only; the header
sections themselves are the shared HeaderLogProperties), and BoundedBodyCapture (two different
concurrency designs). For those 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.
7. Appendix¶
7.1 Related documents¶
- Servlet guide — what the servlet stack decides: the filter and its two registrations, request destruction as the emission point, async exchanges, the chain-wide MDC, the wrappers, and the servlet-only edge cases.
- Container guide — Tomcat, Jetty and Undertow documented individually: destruction models, error paths, pinned deviations, suites.
- Reactive guide
— what the reactive stack decides: the two filter variants, the terminal signal with the
commit-deferred error path, the Reactor context and handler-side MDC, the
DataBuffertee, and the reactive-only edge cases. - The module READMEs — servlet, reactive — each with its quick start and the twin-difference table.
/docs/endpoint-logging-reference.yml— the complete commented configuration reference; every key and default, bound byEndpointLoggingReferenceConfigTestin both modules./docs/elk/README.md— the Elasticsearch component template for theendpoint_*fields and the access pattern behind each mapping decision.- The decision records — ADR-0002 (the trace id is the request id), ADR-0003 (the shared code is inlined), ADR-0004 (the counting id default), ADR-0005 (headers masked by default), ADR-0006 (bodies logged by outcome).
- The generated test-evidence page — every
test of both modules and of
limesium-commonwith its rationale.