Skip to content

Test evidence

Generated by the Docs workflow from the Surefire results of the test run and the rationale comments in the test sources. Do not edit by hand.

The run executed 421 tests (0 failures, 0 errors, 0 skipped) in 42.0s across both modules, against real embedded servers (Tomcat resp. Netty) but without Docker or any external service - see CONTRIBUTING.

Module Component under test Tests Time
limesium-common CountingCorrelationIdGeneratorTest 17 0.1s
limesium-common TraceparentFuzzTest 13 0.0s
limesium-common EndpointLogFieldTest 11 0.3s
limesium-common HeaderMaskingFuzzTest 10 0.0s
limesium-common HeaderLogPropertiesTest 6 0.1s
limesium-common HeaderValueMaskerTest 4 0.3s
limesium-common BodyLogModeTest 3 0.0s
limesium-common MaskingKeyTest 3 0.0s
limesium-common MdcScopeTest 3 0.0s
limesium-common TraceparentTest 3 0.1s
limesium-common CorrelationHeaderValueTest 2 0.0s
limesium-reactive-logging RequestLoggingWebFilterTest 23 0.1s
limesium-reactive-logging RequestLoggingWebFilterMetricsTest 20 0.1s
limesium-reactive-logging RequestLoggingWebFilterBodyAndHeaderTest 19 0.1s
limesium-reactive-logging RequestLoggingAutoConfigurationTest 16 0.2s
limesium-reactive-logging BoundedBodyCaptureTest 9 0.0s
limesium-reactive-logging CoRequestLoggingWebFilterTest 7 0.0s
limesium-reactive-logging JettyServerIntegrationTest 7 0.7s
limesium-reactive-logging ReactorNettyServerIntegrationTest 7 0.6s
limesium-reactive-logging RequestLoggingPropertiesTest 7 0.0s
limesium-reactive-logging TomcatServerIntegrationTest 7 1.8s
limesium-reactive-logging CoRequestLoggingWebFilterCoroutineIntegrationTest 6 0.9s
limesium-reactive-logging RequestLoggingWebFilterIntegrationTest 6 0.7s
limesium-reactive-logging TwinContractTest 6 0.0s
limesium-reactive-logging EndpointLoggingReferenceConfigTest 4 0.0s
limesium-reactive-logging MdcContextPropagationTest 4 0.1s
limesium-reactive-logging RequestLoggingWebFilterTracingAutoPropagationIntegrationTest 3 0.5s
limesium-reactive-logging RequestLoggingWebFilterTracingIntegrationTest 3 0.3s
limesium-reactive-logging HandlerMappingAttributeTest 1 0.0s
limesium-servlet-logging RequestLoggingFilterBodyAndHeaderTest 31 0.1s
limesium-servlet-logging RequestLoggingFilterTest 24 0.0s
limesium-servlet-logging RequestLoggingMetricsTest 17 0.1s
limesium-servlet-logging RequestLoggingFilterTomcatIntegrationTest 13 1.4s
limesium-servlet-logging RequestLoggingFailOpenCounterTest 12 0.4s
limesium-servlet-logging RequestLoggingFilterAsyncTest 11 0.0s
limesium-servlet-logging BoundedBodyCaptureFuzzTest 9 0.0s
limesium-servlet-logging ExchangeLogEmitterTest 9 0.1s
limesium-servlet-logging RequestLoggingAutoConfigurationTest 8 0.2s
limesium-servlet-logging RequestLoggingFilterJettyIntegrationTest 8 0.9s
limesium-servlet-logging RequestLoggingFilterTraceContextTest 8 0.0s
limesium-servlet-logging RequestLoggingFilterUndertowIntegrationTest 8 1.9s
limesium-servlet-logging BoundedBodyCaptureTest 7 0.0s
limesium-servlet-logging RequestLoggingPropertiesTest 7 0.0s
limesium-servlet-logging TwinContractTest 6 0.0s
limesium-servlet-logging EndpointLoggingReferenceConfigTest 3 0.0s
limesium-servlet-logging EndpointMdcCallableInterceptorTest 3 0.0s
limesium-servlet-logging RequestLoggingFilterJettyTracingIntegrationTest 2 0.4s
limesium-servlet-logging RequestLoggingFilterTomcatTracingIntegrationTest 2 0.3s
limesium-servlet-logging RequestLoggingFilterUndertowTracingIntegrationTest 2 0.2s
limesium-servlet-logging HandlerMappingAttributeTest 1 0.0s

limesium-common

75 tests.

BodyLogModeTest

3 tests.

should capture in every mode but never

Rationale

What is tested? the captures property of the three modes - whether a bounded capture must be installed before the outcome is known.

How is success determined? NEVER false, ON_FAILURE and ALWAYS true.

Why does it matter? on-failure needs the bytes although it may discard them; a mode that captured nothing would log an empty body on the one line an operator wants it. Given/When/

should log always and never regardless of the outcome

Rationale

What is tested? the two unconditional modes of logs(failed) - ALWAYS and NEVER answer the same for a clean and for a failed exchange.

How is success determined? ALWAYS true for both outcomes, NEVER false for both.

Why does it matter? only ON_FAILURE is outcome-gated (ADR-0006); the other two must not silently pick up a gate through a shared code path. Given/When/

should log on failure only when the exchange failed

Rationale

What is tested? the one decision the emitters delegate - on-failure discards a success.

How is success determined? true for a failed exchange (outcome not success, or a 4xx), false otherwise.

Why does it matter? this single predicate is the volume switch of ADR-0006. Given/When/

CorrelationHeaderValueTest

2 tests.

should adopt the id shapes callers actually send

Rationale

What is tested? the positive side of the rule - the id formats of gateways, sidecars and the module's own generator.

How is success determined? a UUID, a 21-character base-36 id, a ULID, a hex digest, a value with token punctuation and a value of exactly the maximum length are returned unchanged.

Why does it matter? an over-strict rule would silently replace a caller's id with a generated one - the correlation the header exists for would break without any symptom but a rising generated share. Given/When/

should treat absent blank over-long and non-visible-ascii values as missing

Rationale

What is tested? the negative side of the rule - every value that must count as an absent header so that a fresh id is generated.

How is success determined? null for null, the empty string, whitespace, one character beyond the maximum length, an inner space, a tab, a control character and a non-ASCII letter.

Why does it matter? an accepted value is echoed and written into every log line of the exchange; without the bound its length and character set would be dictated by the peer (code analysis of 2026-09-05, finding 11). Given/When/

CountingCorrelationIdGeneratorTest

17 tests.

Counter behaviour

should increment the counter by one on every call

Rationale

What is tested? the per-call increment of the counter under a zero prefix.

How is success determined? three consecutive ids end in 0, 1, 2 with the width preserved.

Why does it matter? the id is the correlation of one request; a counter that skipped or repeated would silently join or split exchanges. Given

should keep the counter width constant across a base-36 carry

Rationale

What is tested? that the counter keeps its fixed width across a base-36 carry.

How is success determined? call 36 renders as ...0000000z and call 37 as ...00000010 - both eight characters wide.

Why does it matter? this is the exact point where a missing padStart would first show up. Up to value 35 the counter happens to be one character wide either way, so a test that only checks the first few ids would pass against a broken implementation. Given

should keep the counter width constant across a second base-36 carry

Rationale

What is tested? the counter at 36^2 - the carry from two to three significant digits after 1295 calls.

How is success determined? the id before the carry ends in zz, the one after in 100, both eight characters wide.

Why does it matter? a width change on carry would break the fixed-length contract exactly where an unpadded counter would - deep into an instance's lifetime, never in a short test.

should keep the fixed width up to the last counter value the width can hold

Rationale

What is tested? the 21-character contract at its real boundary - the last value the counter width can render - reached through the internal counterStart seam instead of 2.8e12 warm-up calls.

How is success determined? the id at counter 36^8 - 1 still has exactly 21 characters and ends in eight z; the very next id grows to 22 characters, pinning the documented, deliberately unguarded overflow behavior (padStart silently stops applying).

Why does it matter? beyond the width both load-bearing format properties - the fixed split point and the lexicographic ordering - break without any signal. This test is the executable guard the production KDoc points to: narrowing a width constant fails here instead of in production.

should produce a reproducible sequence for a given seed

Rationale

What is tested? the seed seam - two instances with the same prefixSeed and the same counter start.

How is success determined? five ids from each are identical.

Why does it matter? the seam is what makes the generator testable without a mocking library; a hidden second source of randomness would defeat it.

should start the counter at zero

Rationale

What is tested? the initial counter value - the first id of an instance with seed 0.

How is success determined? thirteen zeros of prefix followed by eight zeros of counter.

Why does it matter? the counter width pins where the prefix ends; a counter starting at 1 or padded differently would move the split point. Given

should use different prefixes for different seeds

Rationale

What is tested? the prefix's dependence on the seed - two instances with seeds 1 and 2.

How is success determined? the first 13 characters differ.

Why does it matter? the prefix is the only thing keeping ids of two JVMs apart; a seed that did not reach the prefix would make cross-instance collisions certain. Given

Id format

should pad a small seed to the full prefix width

Rationale

What is tested? the padStart of the prefix - seed 1 renders as a single digit before padding.

How is success determined? the id starts with twelve zeros followed by 1.

Why does it matter? without the padding the prefix length would vary with the seed and the prefix/counter split point would move from id to id. Given

should produce a well-formed id when constructed without an explicit seed

Rationale

What is tested? the production constructor path - the prefix seeded from SecureRandom.

How is success determined? the id still matches the 21-character base-36 contract.

Why does it matter? this is the only path production takes; the seeded tests would keep passing while a broken default (a sign character, a wrong width) shipped unnoticed.

should render a negative seed as an unsigned value without a sign character

Rationale

What is tested? that a negative seed is reinterpreted as an unsigned value rather than rendered with a minus sign.

How is success determined? the prefix still occupies exactly 13 characters drawn from the base-36 alphabet and carries no sign character. The exact rendering is not asserted, because re-deriving it in the test would only duplicate the production code.

Why does it matter? half of all values a random source produces are negative. Using toString instead of toUnsignedString would put a - into every second id, silently changing both the id length and the character set that downstream consumers see. Given

should render an id of exactly twenty-one lowercase alphanumeric characters

Rationale

What is tested? the format contract of ADR-0004 - 13 prefix plus 8 counter characters, base-36 digits only.

How is success determined? length 21 and the whole id matches [0-9a-z].

Why does it matter? the fixed length is what makes the id splittable and sortable downstream; a stray uppercase or sign character would break consumers that key on the alphabet. Given

should render seed 35 as the last single-digit value of base 36

Rationale

What is tested? the top of the base-36 digit alphabet in the prefix - 35 is the last value rendered by one character.

How is success determined? the id starts with twelve zeros followed by z.

Why does it matter? pins that the radix is 36 and the digits are lowercase; a radix of 32 or 62 or an uppercase alphabet would change the character set the index sees.

should render seed 36 as a carry into the second digit

Rationale

What is tested? the first carry of the prefix rendering - 36 is 10 in base 36.

How is success determined? the id starts with eleven zeros, then 10.

Why does it matter? together with the seed-35 test this pins the radix from both sides - the boundary a wrong radix or a toString() without radix would cross first. Given

Lexicographic ordering

should hand out ids that sort in allocation order

Rationale

What is tested? that ids handed out by one instance sort lexicographically in the order they were allocated.

How is success determined? sorting the generated sequence leaves it unchanged. The range deliberately spans the carry at 36, which is where a width regression would break the ordering first.

Why does it matter? this property is what makes the id usable as a tiebreaker when log entries share a timestamp. It is not enforced by any type - it rests entirely on the fixed field widths, and a change to those constants would drop it silently. Given

should hand out ids that sort in allocation order across a carry

Rationale

What is tested? lexicographic ordering of consecutive ids around the 36^2 carry (twelve ids from 1290 on).

How is success determined? the list is sorted as strings.

Why does it matter? fixed width plus a lowercase alphabet is what makes string order equal allocation order; a carry that broke it would misorder exactly the ids an operator sorts by. Given

Thread safety

should hand out distinct ids under concurrent access

Rationale

What is tested? that concurrent calls never hand out the same id twice.

How is success determined? the number of distinct ids equals the number of calls. Since uniqueness within an instance is a guarantee rather than a probability, any duplicate is a hard failure, not a flake.

Why does it matter? the counter is the one piece of mutable shared state in the class. Replacing the AtomicLong with a plain Long - or, more plausibly, with a ThreadLocal in an attempt to avoid contention - would produce duplicates here. Given

should keep the id format intact under concurrent access

Rationale

What is tested? the shared AtomicLong under eight threads drawing 500 ids each.

How is success determined? every id matches the 21-character base-36 contract and the run finishes within the timeout.

Why does it matter? the generator sits on every request thread; a race in the counter or the rendering would surface only under contention. Given

EndpointLogFieldTest

11 tests.

Drop the field not the event

should drop a badly typed field but keep the event and its other fields

Rationale

What is tested? the fail-open contract of the addKeyValue(field, value) overload.

How is success determined? the event is logged, the well-typed field survives, the ill-typed field is absent - the statement never throws.

Why does it matter? the exchange line is the observability of the request path; a type slip in one field must not take the whole statement (and the surrounding request) down with it. Given/

The component template

should be a component template, claiming no indices of its own

Rationale

What is tested? the top-level keys of the shipped template.

How is success determined? only template and _meta - no index_patterns.

Why does it matter? a component template composes into the host's index template; one with its own patterns would compete on priority and claim data streams that are the host pipeline's decision. Given /

should keep every payload field out of the index

Rationale

What is tested? the mapping half of the sensitivity rule - headers and bodies must not be searchable, no matter how the rest of the template changes.

How is success determined? index false AND doc_values false asserted explicitly, not via the field set - a field silently re-typed to a searchable keyword would still pass the set check.

Why does it matter? selection and masking in code are the real protection; the mapping is the second line, so a value that slips through cannot at least be searched for deliberately.

should keep the high-cardinality URL fields out of doc values but leave them searchable

Rationale

What is tested? the repetition-factor split of the path pair - the decision an unsuspecting edit is most likely to undo ("why is url_path not aggregatable? let me fix it").

How is success determined? path and query have doc_values off but stay indexed; the template half keeps its doc values as the aggregation counterpart.

Why does it matter? the resolved path appears in about one line each - doc values on it grow an ordinal dictionary to the document count and buy only singleton buckets, while endpoint_url_template is the field that answers "which endpoint is slow". Given / When /

should map every field exactly as its ELK line states

Rationale

What is tested? type, index and doc_values of ALL thirteen fields against the access pattern each constant's ELK: KDoc line states (comment audit round 2 of 2026-09-05, CA-13 - before, only headers, bodies, path and query were pinned).

How is success determined? every field's mapping equals its expected (type, index, doc_values) triple, absent attributes counting as the Elasticsearch default true.

Why does it matter? the KDoc claims the ELK lines are tested; a template edit that re-types a field or drops doc values on an aggregation field must fail the build, not the dashboards.

should map exactly the fields this module emits

Rationale

What is tested? that the template and the enum describe the same field set.

How is success determined? set equality - it fails in BOTH directions, a field added to the enum without a mapping AND a mapping left behind for a removed field.

Why does it matter? an unmapped field is not an error at index time - Elasticsearch maps it dynamically, and for a body or a header that means the value becomes SEARCHABLE, the one outcome the mapping guide's sensitivity rule forbids.

should map the numeric and boolean shapes the code guarantees

Rationale

What is tested? the component template's types for the non-keyword fields - duration, status, slow, async.

How is success determined? long, short, boolean, boolean respectively.

Why does it matter? the shape checked() enforces in code and the type the index expects must describe the same value; a status mapped as long would invite summing a label. Given / When /

Type guarantee

should pass a correctly typed value through unchanged

Rationale

What is tested? format() for values of the shape each field declares.

How is success determined? the identical value comes back for a String, a Long, an Int status and a Boolean.

Why does it matter? format checks, it never converts; a conversion would let a mistyped value slip through as a coerced one. Given/

should reject a value of the wrong type naming the field

Rationale

What is tested? format() for an Int where the field declares Long.

How is success determined? IllegalArgumentException naming the field, the declared type and the actual type.

Why does it matter? the emitter is the only writer; a shape violation must fail loudly with a diagnostic that names the field, not surface as a mapping conflict in the index weeks later. Given/

Wire names

should be the literal strings the component template maps

Rationale

What is tested? every wire name, spelled out as a literal - independently of the enum, so a rename cannot pass by asserting a value against itself.

How is success determined? all thirteen names match exactly.

Why does it matter? once the template is composed into a pipeline, changing a name is a breaking change for every dashboard and alert keying on it - the compiler cannot see that. Given/When/

should prefix every wire name with endpoint and keep them unique

Rationale

What is tested? the naming contract of the whole family in one place.

How is success determined? every wire name starts with 'endpoint_', is lower snake_case, and no two fields collide.

Why does it matter? the names are index-side contract - a stray prefix or a duplicate silently splits one logical field into two that no dashboard knows about. Given/

HeaderLogPropertiesTest

6 tests.

should keep supporting the wildcard in includes and masked

Rationale

What is tested? the two documented wildcard positions - includes = [*] and masked = [*] - at construction and in select().

How is success determined? constructed without rejection; the included header is logged with its value masked.

Why does it matter? the wildcard is rejected in excludes and unmasked; the two places it IS allowed must keep working or the default configuration breaks. Given/

should mask every logged header by default and let unmasked names through in plaintext

Rationale

What is tested? ADR-0005 - the default section masks everything it logs; the plaintext set is an explicit allowlist that wins over the mask.

How is success determined? with a wildcard include and NOTHING said about masking, both headers are fingerprinted; naming one in unmasked renders exactly that one in plaintext.

Why does it matter? includes: ["*"] is the documented debugging move; with the old defaults it put every header in the log in plaintext because masking was a second, empty list. Given

should mask through the masker handed to select so a host bean decides the shape

Rationale

What is tested? the masker is an injected collaborator of the selection, not a hard-wired fingerprint - the shape of a masked value is the host's policy.

How is success determined? a section with a masked name renders the masker's output for that header and the plain value for every other; the plaintext of the masked header never appears.

Why does it matter? a compliance regime may forbid unkeyed hashes; the bean is the one place to satisfy it for both twins at once.

should reject the wildcard in excludes at construction time

Rationale

What is tested? the binding-time validation for a plausible misconfiguration - '*' means something in includes and masked, but was a silent no-op in excludes.

How is success determined? construction fails with a message naming the alternative.

Why does it matter? a wildcard exclude reads like "log nothing", would have logged EVERYTHING the includes selected, and gave no feedback - the classic silent misconfiguration. Given/

should reject the wildcard in unmasked at construction time

Rationale

What is tested? the plaintext set is an explicit list of names by design.

How is success determined? construction fails with a message naming the alternative (empty masked).

Why does it matter? unmasked: ["*"] would be the one-token way back to plaintext-everything; the way back must be the visible removal of the mask, not an addition that reads harmless. Given/When

should switch masking off only through an explicitly emptied masked list

Rationale

What is tested? select() with masked = emptyList() - the one configuration that logs values in plaintext.

How is success determined? the included Authorization value appears verbatim.

Why does it matter? plaintext must be an explicit, visible decision (ADR-0005), never the accidental result of another list.

HeaderMaskingFuzzTest

10 tests.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[10]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[1]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[2]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[3]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[4]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[5]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[6]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[7]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[8]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

selection_and_masking_uphold_their_contract(FuzzedDataProvider)[9]

Rationale

What is tested? HeaderLogProperties construction and select() plus the default masker against arbitrary name lists and header maps - a rejection only for the documented cases, no throw from select(), include-minus-exclude once per name, masked values only as the fingerprint.

How is success determined? no exception and no oracle violation for any input Jazzer generates - a masked value in plaintext or an unexpected selection fails the run.

Why does it matter? header names and values are peer- and operator-controlled input on every exchange; a plaintext leak through an unforeseen name shape is a secret in the logs.

HeaderValueMaskerTest

4 tests.

should key the fingerprint with an HMAC that keeps the shape and changes the digits

Rationale

What is tested? the keyed variant - same length:hex16 shape, different 64 bits, pinned as known answers (HMAC-SHA256 over UTF-8, first 8 bytes) so the format cannot drift silently - the same literals the outbound sibling legatium pins.

How is success determined? two keys give two different fingerprints, both differ from the unkeyed one, and each matches its literal.

Why does it matter? a keyed fingerprint is what makes masked guess-proof; the literals are the contract a peer sharing the key can rely on. Given/When/Then

should reject a blank key

Rationale

What is tested? the blank-key guard of keyed and forKey.

How is success determined? both throw IllegalArgumentException for whitespace-only keys.

Why does it matter? whitespace is not a secret; a masker keyed with it would look guess-proof and be trivially guessable. Given/When/

should render identical values identically under the same key

Rationale

What is tested? stability of the keyed fingerprint - within one masker and across two maskers built from the same key.

How is success determined? the same value renders the same string in both cases.

Why does it matter? a per-instance nonce or salt would keep the shape but break the correlation of a masked token across events, twins and the outbound sibling. Given/When/

should select the unkeyed default for an empty key and the keyed variant otherwise

Rationale

What is tested? forKey - the factory the masking-key property drives.

How is success determined? the empty key yields the DEFAULT instance itself; a key yields the keyed fingerprint with its pinned literal.

Why does it matter? the property is the one source of truth for keying; a factory that keyed an empty key or unkeyed a set one would silently change every logged fingerprint. Given/When/Then

MaskingKeyTest

3 tests.

should compare by value

Rationale

What is tested? value equality, which the properties data classes' equals/copy rely on.

How is success determined? equal keys are equal with equal hash codes; different keys are not.

Why does it matter? a data class holding an identity-compared key would break its own copy/equals contract. Given/When/Then

should redact a key in toString and keep an empty one visibly empty

Rationale

What is tested? the rendering the properties classes inherit through their generated toString.

How is success determined? a key renders as the marker and never as its text; the empty key renders empty.

Why does it matter? a properties dump must not leak the secret, and an operator must still see whether a key is set at all. Given/When/Then

should reject a blank key but accept an empty one

Rationale

What is tested? the binding-time rule of the value - empty means unkeyed, blank is a misconfiguration.

How is success determined? whitespace fails construction naming the property; the empty key is NONE.

Why does it matter? a whitespace key would silently key the fingerprint with a worthless secret. Given/When/Then

MdcScopeTest

3 tests.

should attach a failing rollback to the install exception instead of replacing it

Rationale

What is tested? the partial-install rollback of MdcScope when the rollback itself fails - a put that throws followed by a remove that throws.

How is success determined? the install exception is the one thrown, the rollback failure rides along as suppressed, and the keys put before the failure are gone.

Why does it matter? an exception that replaced the original would hide the root cause; a rollback that gave up at the first failure would leave half an identity on a pooled thread.

should restore every remaining key when one restoration fails and attach later failures as suppressed

Rationale

What is tested? best-effort restoration on close - the adapter fails on the FIRST key's remove.

How is success determined? close throws that failure, but the other keys were still restored.

Why does it matter? a restoration loop that stops at the first failure leaves module-owned MDC on the thread for every later key - exactly the contamination the scope exists to prevent.

should roll back the keys already installed when a later put fails and keep the install exception

Rationale

What is tested? the partial-install rollback - the adapter fails on the THIRD key.

How is success determined? the install exception propagates as-is, and the two keys installed before it are gone from the MDC (pooled-thread hygiene).

Why does it matter? half an identity on a pooled thread contaminates the next request's logs.

TraceparentFuzzTest

13 tests.

parser_upholds_its_contract(FuzzedDataProvider)[10]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[11]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[12]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[13]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[1]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[2]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[3]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[4]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[5]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[6]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[7]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[8]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

parser_upholds_its_contract(FuzzedDataProvider)[9]

Rationale

What is tested? Traceparent.parse against arbitrary input - the negative oracle (never throws, accepts only well-formed lowercase-hex ids of fixed length, neither all zeros) and the positive oracle (a structurally valid version-00 header built from fuzzed hex always parses).

How is success determined? no exception and no verdict that contradicts either oracle for any input Jazzer generates, the checked-in corpus included.

Why does it matter? the header is caller-controlled; a throw would break a request on the fail-open path, a false accept would join the event to a foreign trace, a false reject would drop it.

TraceparentTest

3 tests.

should accept every conformant header of the shared fixture with the expected identifiers

Rationale

What is tested? Traceparent.parse against every valid line of the shared conformance fixture.

How is success determined? each header parses to exactly the trace id and span id the fixture names.

Why does it matter? the fixture is the one copy of the W3C contract both twins and Legatium share; a parser drifting from it would join or drop traces differently per module.

should reject every non-conformant header of the shared fixture

Rationale

What is tested? identifier shape, all-zero ids, version (two lowercase hex, not ff, exactly four fields for 00), flags (two lowercase hex) and structure - the cases the fixture enumerates.

How is success determined? null for each.

Why does it matter? an accepted invalid header lands under the traceId/parentSpanId MDC keys and produces joins the tracing infrastructure does not contain.

should treat an absent header as no trace context

Rationale

What is tested? parse(null) - the traceless request.

How is success determined? null, no exception.

Why does it matter? most requests carry no traceparent; the absent case is the hot path and must not throw on the fail-open route. Given/When/Then

limesium-reactive-logging

155 tests.

BoundedBodyCaptureTest

9 tests.

Freeze semantics

should freeze idempotently and keep a zero-byte capture absent

Rationale

What is tested? freeze() called twice on an untouched capture, then the logged value and the byte count.

How is success determined? no exception, the logged value is null and totalBytes is 0.

Why does it matter? the emitter freezes first and a late terminal callback may freeze again; a zero-byte body must stay an absent field, not an empty string.

should ignore every mutation after freeze and keep the snapshot stable

Rationale

What is tested? post-freeze capture, count and clear are no-ops.

How is success determined? the logged value and totalBytes after the late mutations equal the values at freeze time.

Why does it matter? the emitter reads body text and size as two separate calls; a mutation between them - or during them - would make the logged body and the metric disagree.

Late delivery through the tee

should not let a buffer delivered after the freeze reach the capture

Rationale

What is tested? the hand-off the cancellation race exercises - the response tee is still subscribed when the emission freezes the capture, and the publisher then delivers an already-requested buffer.

How is success determined? the buffer passes the tee (downstream is unaffected) but the capture's text and count are unchanged.

Why does it matter? doFinally(CANCEL) runs immediately after cancellation is forwarded while an onNext may still be in flight; without the freeze the log snapshot would be taken from a buffer that another thread is mutating.

Read state

should ignore marks once frozen so the emitted state is a consistent snapshot

Rationale

What is tested? the read state follows the freeze contract of every other mutation.

How is success determined? a completion signal arriving after freeze leaves the state at PARTIAL.

Why does it matter? the emitter freezes first and reads second; a mark slipping in between would make the counter disagree with the body text and size logged for the same exchange.

should start unread and move to partial on start and to complete on completion, never backwards

Rationale

What is tested? the read-state transitions of the capture - unread, partial on markStarted, complete on markCompleted, and a later markStarted.

How is success determined? UNREAD, then PARTIAL, then COMPLETE, and COMPLETE again after the late start.

Why does it matter? the state is the state tag of the read counter; a regression from complete to partial on a second subscription would count a fully read body as abandoned.

Truncation at a character boundary

should drop an incomplete trailing UTF-8 sequence instead of decoding a replacement character

Rationale

What is tested? byte-bounded truncation of multi-byte text - the cap counts bytes, so it can split a character.

How is success determined? with a 2-byte cap over "h\u00e9" (3 bytes: 68 c3 a9) the logged prefix is "h", not "h\uFFFD"; the byte count stays exact.

Why does it matter? a replacement character in the logged prefix is corruption the reader cannot distinguish from corrupt input.

should drop an incomplete trailing sequence of another variable-width charset

Rationale

What is tested? decodeTruncated on a Shift_JIS body cut inside a two-byte character.

How is success determined? the complete character survives, the cut one is dropped, the truncation note counts all three bytes.

Why does it matter? the capture limit bounds bytes, not characters, for every charset; a decoder that only handled UTF-8 would corrupt the logged prefix for Japanese payloads.

should keep a complete multi-byte character that ends exactly at the cap

Rationale

What is tested? the boundary case of the byte cap - a two-byte character whose last byte is the last captured byte.

How is success determined? the character is logged intact, followed by the truncation note.

Why does it matter? an off-by-one in the underflow handling would drop a character that was fully captured.

should still replace malformed bytes inside the prefix

Rationale

What is tested? a lone continuation byte in the middle of a truncated capture.

How is success determined? the malformed byte renders as the replacement character, the rest of the prefix is intact.

Why does it matter? endOfInput=false must only spare the TRAILING incomplete sequence; malformed bytes inside the prefix are the peer's problem and must stay visible as such.

CoRequestLoggingWebFilterCoroutineIntegrationTest

6 tests.

should carry the endpoint MDC into a log line written inside a suspend handler after a dispatcher hop

Rationale

What is tested? the coroutine variant's defining feature end to end - CoWebFilter publishes the MDCContext-carrying coroutine context to the handler invocation, and MDCContext restores the map on EVERY resumption, so a log statement after withContext(Dispatchers.Default) - a real thread change away from the event loop - still carries the identity.

How is success determined? the handler's own log event (a different logger, captured independently) carries endpoint_request_id, endpoint_method and endpoint_route in its MDC; the response body confirms the handler read the same id from MDC on the hopped thread.

Why does it matter? handler-side correlation is the whole reason the variant exists; the unit test proves it over a mock exchange, only a real runtime proves the WebFlux hand-off. Given/

should log a real round trip with both bodies through a suspend handler

Rationale

What is tested? the coroutine variant on real Netty - a POST whose suspend handler reads and echoes the body, the correlation echo, both tees.

How is success determined? the client sees the echo and its correlation id; one INFO event carries both bodies, the template and the id in the MDC, without an endpoint_async field.

Why does it matter? the coroutine variant shares the ExchangeLifecycle with the Reactor variant; only a real dispatch through kotlinx's bridge proves the line is identical. Given/

should log the rendered 500 via the commit-deferred emission when a suspend handler throws

Rationale

What is tested? the suspend try/catch signal mapping against Boot's REAL error handler - the Throwable branch rethrows, the terminal handling defers the emission to the commit callback, and across the coroutine-to-Reactor bridge kotlinx's stack-trace recovery may hand the container a COPY whose cause is the original.

How is success determined? the client sees 500, the single ERROR event carries 500, outcome failure, and the original message is reachable through the cause chain; the echo survives.

Why does it matter? the variant must not lose error semantics or the status to the bridge. Given/

should log the status and header a later commit action applies on a real suspend error response

Rationale

What is tested? the commit-action ordering against a REAL container, reached through the coroutine variant's error branch - a downstream filter's beforeCommit action turns the rendered 500 into a 503 and adds a header; the module's callback, registered at the error signal, must run behind it.

How is success determined? the client receives 503 plus the header, and the single ERROR event carries 503 and the header.

Why does it matter? the deferral is shared lifecycle code, but the coroutine branch reaches it through a rethrow across the bridge - the container must still order it correctly. Given/

should record the handler pattern of a real suspend dispatch

Rationale

What is tested? the BEST_MATCHING_PATTERN attribute WebFlux sets for a suspend handler, read at emission.

How is success determined? endpoint_url_path carries the expanded path, endpoint_url_template the pattern with its placeholder.

Why does it matter? the template is the aggregation half of the path pair; a coroutine dispatch that left the attribute unset would collapse every route into one bucket. Given/

should run the coroutine variant as the single active filter without context-propagation accessors

Rationale

What is tested? the explicit variant selection (variant=coroutine) and the README's promise that the endpoint_* ThreadLocalAccessors are installed ONLY while the Reactor variant owns the slot - the coroutine variant carries the MDC natively via MDCContext.

How is success determined? exactly one EndpointLoggingFilter bean, of the coroutine type; no Reactor filter; no endpoint_* accessor in the JVM-global ContextRegistry.

Why does it matter? accessors registered alongside the coroutine variant would be dead weight at best and a false startup warning at worst. Given/

CoRequestLoggingWebFilterTest

7 tests.

should defer the error emission until the response commits with the rendered status

Rationale

What is tested? the coroutine variant's error path - the chain throws, the response is still uncommitted, then upstream error handling renders and commits.

How is success determined? the exception propagates (or its stacktrace-recovered copy with the original as cause), no event before the commit, one ERROR event with status 500 and outcome failure after it.

Why does it matter? emitting at the throw would log a status the error handler has not rendered yet; the commit callback is the one point where it is final.

should hand suspend handlers the endpoint MDC across a real dispatcher hop

Rationale

What is tested? the coroutine-native MDC parity - the chain runs under MDCContext, CoWebFilter publishes the coroutine context to the handler invocation, and MDCContext restores the identity on every resumption.

How is success determined? a simulated suspend handler (running the published context, hopping to Dispatchers.Default) sees all three endpoint_* MDC entries on a foreign thread; nothing leaks onto the calling thread.

Why does it matter? this is THE reason the coroutine variant exists - handler logs in coroutine apps carry the correlation id with no context-propagation dependency.

should log outcome cancelled when the client disconnects

Rationale

What is tested? the coroutine variant's cancel path - a never-completing chain whose subscription is disposed.

How is success determined? one WARN event with outcome cancelled and no status field.

Why does it matter? a disconnect is the reactive disposition a servlet request cannot have; inventing a status for it would misreport an abandoned exchange as answered.

should log the identical line format of the reactor variant

Rationale

What is tested? output identity - both variants share the ExchangeLifecycle, and this pins it end to end through the CoWebFilter bridge.

How is success determined? the exact message string and field family of the reactor variant's test.

Why does it matter? the variant choice is a classpath detail; dashboards must never see it.

should observe status and header mutations of later commit actions on the deferred error path

Rationale

What is tested? the commit-action ordering of the deferred error path in the COROUTINE variant - the callback is registered in the catch block, after the chain ran, behind every action a downstream filter registered.

How is success determined? a later action turns the rendered 500 into a 503 and adds a selected header; the single ERROR event carries both.

Why does it matter? parity - both variants share the lifecycle, and both must log what the response applied, not what an early callback saw.

should preserve ambient MDC entries under the endpoint overlay inside suspend handlers

Rationale

What is tested? the ADDITIVE MDC contract - MDCContext installs its map as the coroutine's COMPLETE MDC, so the filter must snapshot the ambient MDC (trace ids, host keys) and overlay only the endpoint_* identity; an implementation handing MDCContext just the three identity keys would delete everything else on every resumption.

How is success determined? inside the handler on a foreign dispatcher thread, the seeded trace and host entries are visible TOGETHER with all three endpoint_ keys; after completion the calling thread's MDC still carries the seeded entries and no endpoint_ residue.

Why does it matter? replacing the ambient MDC silently destroys trace correlation in every coroutine application that already carries tracing or tenant context - the exact data loss the previous single-key proof could never detect.

should run the chain without handler MDC and still log when the ambient MDC snapshot throws

Rationale

What is tested? the fail-open boundary around the ambient MDC snapshot - MDC.getCopyOfContextMap() is a host-adapter call made by the filter, outside the handler's try/catch.

How is success determined? the chain runs and completes normally, nothing propagates to the host, the exchange event is emitted with outcome success, the degradation is counted stage=wiring, and the open-exchanges gauge is back at zero.

Why does it matter? before the guard the adapter failure failed every coroutine request AND leaked the gauge entry, blamed on application code - the one outcome the fail-open contract forbids.

EndpointLoggingReferenceConfigTest

4 tests.

should bind the body modes by their kebab-case names and refuse the former booleans

Rationale

What is tested? the documented spellings never / on-failure / always bind (Boot's lenient enum conversion), and a leftover true from the boolean era fails the binding loudly.

How is success determined? the two modes bound; true raises a BindException.

Why does it matter? a silently ignored true would switch body logging OFF for an operator who believed it on - the migration must be visible at startup. Given

should bind the reference configuration to exactly the built-in defaults

Rationale

What is tested? that every VALUE in the reference YAML is the built-in default.

How is success determined? binding the file yields an object equal to RequestLoggingProperties() - the data-class equality covers every property at once.

Why does it matter? the reference promises "copy it, and nothing changes"; a drifted default would silently break that promise for everyone who copies the block. Given/

should document in the own reference nothing but the variant key

Rationale

What is tested? the single-source rule of the two reference files - the shared file documents the whole namespace, this module's own file exactly the one reactive-only key.

How is success determined? key set of the own file == {"variant"}; the shared file does not document the variant key.

Why does it matter? a second full copy of the shared block was a drift surface; this assertion turns the single-source promise in both file headers into a build-breaking contract. Given/

should document only keys that actually exist

Rationale

What is tested? that the reference contains no stale or misspelled keys - the Binder silently IGNORES unknown keys, so the equality test above cannot catch a typo on its own.

How is success determined? every endpoint-logging.* key in the YAML is one of the known property names.

Why does it matter? a documented key that does not bind is worse than an undocumented one - readers copy it and believe it works.

HandlerMappingAttributeTest

1 tests.

should mirror the best-matching-pattern attribute name WebFlux actually uses

Rationale

What is tested? the mirrored attribute-name literal against WebFlux's own HandlerMapping constant.

How is success determined? the two strings are equal.

Why does it matter? the filter reads the pattern by attribute name; a WebFlux rename would silently drop endpoint_url_template from every event without this pin. Given/When/

JettyServerIntegrationTest

7 tests.

should log a real round trip with both bodies through this server

should log the rendered 500 via the commit-deferred emission on this server

should log the status and header a later commit action applies on this server's error response

should pin that a form body WebFlux parses bypasses the request tee on this server

should record the handler pattern of a real dispatch on this server

should run on this server

should run the reactor variant as the single active filter on this server

MdcContextPropagationTest

4 tests.

should bridge one MDC entry through the accessor and clear it on the empty restore

Rationale

What is tested? the ThreadLocalAccessor contract of MdcEntryThreadLocalAccessor in isolation - key, read, write and the no-value restore that context-propagation calls when the previous value was absent.

How is success determined? the key is the MDC key; getValue mirrors the MDC; setValue(value) writes the entry; setValue() removes it.

Why does it matter? the restore path is what keeps a pooled scheduler thread from carrying one exchange's identity into the next operator (code analysis of 2026-09-05, finding 6).

should not deliver handler MDC under Boot's default limited propagation mode

Rationale

What is tested? the shipped reality of the propagation prerequisite - accessors registered by the auto-configuration do NOT suffice; Boot's default spring.reactor.context-propagation mode limited never calls Hooks.enableAutomaticContextPropagation(), so an ordinary map operator sees no endpoint_* MDC.

How is success determined? with the REAL ReactorAutoConfiguration and no property set, all three endpoint_* MDC reads inside map on a foreign scheduler thread return null.

Why does it matter? this is the negative boundary the previous test suite could not see - it manually enabled the hook and thereby proved a setup the module does not ship. This pin makes the documented auto prerequisite regression-visible.

should register the accessors idempotently under the endpoint keys

Rationale

What is tested? the registrar's idempotence against the JVM-global registry - a context refresh or a second Spring context must not register duplicates.

How is success determined? after registering twice, exactly one accessor per endpoint_* key exists.

Why does it matter? duplicated accessors would apply and restore the same key twice per operator - wasted work at best, restore-order surprises at worst. Given/

should restore the exchange identity into handler MDC across a real thread hop in auto mode

Rationale

What is tested? the full parity chain under the SUPPORTED activation mode - Boot's real ReactorAutoConfiguration reads spring.reactor.context-propagation=auto and enables the automatic-propagation hook; the filter's contextWrite plus the registered accessors then restore the identity inside a plain map operator.

How is success determined? the handler sees all three endpoint_* MDC entries although it runs on a boundedElastic thread that never had them; the hop is proven by the thread name; nothing leaks onto the test thread.

Why does it matter? this is the reactive substitute for the servlet twin's chain-wide MdcScope - and the proof that the documented prerequisite is sufficient, driven through Boot's own configuration path instead of a manually enabled hook.

ReactorNettyServerIntegrationTest

7 tests.

should log a real round trip with both bodies through this server

should log the rendered 500 via the commit-deferred emission on this server

should log the status and header a later commit action applies on this server's error response

should pin that a form body WebFlux parses bypasses the request tee on this server

should record the handler pattern of a real dispatch on this server

should run on this server

should run the reactor variant as the single active filter on this server

RequestLoggingAutoConfigurationTest

16 tests.

should back off both variants behind a host-defined filter

Rationale

What is tested? the host-override contract of the SHIPPED system - a host bean of the EndpointLoggingFilter contract must silence both auto-configurations, not just the one the isolated test loads.

How is success determined? the host's filter is the single EndpointLoggingFilter; neither variant bean exists beside it.

Why does it matter? a partial back-off would run the host's filter AND a module variant - every exchange logged twice. Given/

should back off entirely when disabled by the identical property

Rationale

What is tested? endpoint-logging.enabled=false on the reactive stack.

How is success determined? no filter, no defaults, no properties bean.

Why does it matter? the master switch is one key for both twins; a bean surviving the switch would still register itself into the filter chain. Given/

should bind the identical properties namespace

Rationale

What is tested? four endpoint-logging.* keys bound into the reactive module's properties class.

How is success determined? logger name, wildcard include, masked list and the measuring flag carry the configured values.

Why does it matter? the namespace is a cross-stack contract; a key that bound on the servlet twin but not here would break the twin symmetry silently. Given/

should fail the context start when the coroutine variant is demanded but its libraries are missing

Rationale

What is tested? variant=coroutine is a requirement, not a preference - silently serving the Reactor variant would hide a classpath mistake.

How is success determined? with the coroutine classes hidden, the context fails with a message naming the missing libraries.

Why does it matter? a host demanding the coroutine variant relies on its native handler MDC; a silent Reactor fallback would surface the classpath mistake only as MDC-less production logs. Given/

should fall back to the reactor variant when the coroutine classpath is missing

Rationale

What is tested? the classpath fallback branch of the shipped system - the coroutine libraries are OPTIONAL dependencies, so most consumers run without them.

How is success determined? with CoWebFilter/MDCContext hidden from the classloader, exactly one filter registers and it is the Reactor variant.

Why does it matter? this is the majority consumer configuration, and no test covered it - a broken @ConditionalOnClass would leave those applications with no filter or a startup failure. Given/

should force the reactor variant by property although the coroutine libraries are present

Rationale

What is tested? the explicit override of the classpath-based selection - a Reactor-only host that pulls the coroutine libraries in transitively must be able to say so.

How is success determined? with endpoint-logging.variant=reactor on the FULL classpath, the single filter is the Reactor variant.

Why does it matter? without the switch the variant was decided by transitive dependencies the host may not even know about. Given/

should key the default masker from the masking-key property

Rationale

What is tested? the property path to a guess-proof fingerprint - no host bean needed.

How is success determined? with masking-key set, the masker bean renders the keyed fingerprint, not the unkeyed default.

Why does it matter? keying is the documented answer to "masked is not a security boundary for guessable values"; it must be reachable from application.yml alone. Given/When

should let a host HeaderValueMasker back the default masker off

Rationale

What is tested? the masker is a @ConditionalOnMissingBean bean like the time source and the id generator - a host pins its own rendering (a keyed HMAC, a fixed ***) once.

How is success determined? with a host bean the context holds exactly one HeaderValueMasker, the host's, and the module's default backed off.

Why does it matter? the properties decide which values are masked; the bean is the host's only handle on HOW - it must win over the default in the shipped configuration. Given/

should not warn about context propagation when the coroutine variant owns the filter slot

Rationale

What is tested? the initializer's activation rule - the accessors and the propagation-mode warning belong to the REACTOR variant only.

How is success determined? with both auto-configurations shipped (coroutine variant selected) and the propagation mode unset, no WARN is logged by the propagation initializer.

Why does it matter? the coroutine variant delivers handler MDC natively; a warning pushing hosts towards a global Reactor hook they do not need is false noise at every startup.

should register the MDC accessors when context-propagation is on the classpath

Rationale

What is tested? the initializer bean and its writes into the JVM-global ContextRegistry.

How is success determined? the bean exists and the registry carries one accessor per endpoint_* MDC key.

Why does it matter? handler-side MDC for the Reactor variant rests on these accessors; without them the identity would vanish on the first thread hop.

should register the meters in a host-provided registry and let a host filter win

Rationale

What is tested? a host MeterRegistry and a host filter bean beside the auto- configuration.

How is success determined? the host's filter is the only one, and the fail-open counters are pre- registered in the host registry.

Why does it matter? overriding the filter must keep the wiring, and the meters must land where the host exports them, not in a private registry. Given/

should register the web filter and the defaults in a reactive web application

Rationale

What is tested? the auto-configuration alone in a reactive web context.

How is success determined? exactly one RequestLoggingWebFilter and one each of the three injectable defaults.

Why does it matter? the filter is picked up by WebFlux through its Ordered contract; a missing or duplicated bean would log nothing or twice. Given/

should select the coroutine variant when the coroutine libraries are present

Rationale

What is tested? the shipped two-auto-configuration system on the full classpath (both kotlinx-coroutines libraries present, as on this test classpath).

How is success determined? exactly ONE EndpointLoggingFilter, and it is the coroutine variant - the Reactor variant backed off via @ConditionalOnMissingBean.

Why does it matter? a regression in condition names, ordering (before = ...), or the missing-bean back-off would register two filters (duplicate events) or the wrong variant - invisible to any test that loads one auto-configuration in isolation. Given/

should ship both auto-configurations through the imports resource

Rationale

What is tested? the actual registration file - the runner tests above wire the classes directly, so a missing or misspelled imports entry would stay invisible to them.

How is success determined? the merged AutoConfiguration.imports resources on the classpath contain both variant auto-configurations by fully qualified name.

Why does it matter? the imports resource IS the module's activation in a consumer application; without the entry nothing else in this test class describes shipped behavior. Given/

should start and register the accessors beside two host-defined filters

Rationale

What is tested? the propagation initializer with an AMBIGUOUS filter slot - two host-defined Reactor filters, a constellation the back-off contract permits.

How is success determined? the context starts, both host filters exist, the module's own filter backed off, and the accessors are registered because at least one Reactor variant owns a slot.

Why does it matter? resolved through ObjectProvider.getIfAvailable() the initializer threw NoUniqueBeanDefinitionException and failed the context start from a logging library (code analysis of 2026-09-05, finding 4). Given/

should warn at startup when automatic context propagation is not configured

Rationale

What is tested? the validated prerequisite of the handler-MDC feature - Boot's default spring.reactor.context-propagation=limited does not restore MDC around ordinary operators, so registering the accessors without the auto mode must be called out at startup.

How is success determined? with the property unset the initializer emits one WARN naming the property; with auto configured it stays silent.

Why does it matter? without the warning the feature fails silently - handler logs simply lack the correlation id, which is exactly the defect class the original KDoc promise concealed.

RequestLoggingPropertiesTest

7 tests.

Correlation id header

should accept every token character of a field name

Rationale

What is tested? the correlation-header validation against the full RFC 9110 tchar set.

How is success determined? a name using every token character is accepted unchanged.

Why does it matter? the validation must reject non-tokens without rejecting legal but unusual names; an over-strict pattern would fail a host's existing header name at start. Given/

should reject a correlation header name outside the HTTP field-name grammar

Rationale

What is tested? binding-time validation of the header NAME - the name is written onto every response, and a server adapter that validates field names rejects a non-token at runtime on every request.

How is success determined? whitespace, separators and a non-ASCII character fail construction with a message naming the property.

Why does it matter? a runtime rejection degrades the filter to an unlogged pass-through for ALL traffic while the application stays healthy - an observability outage nobody sees. Given/When/

Masking key

should redact the masking key in toString

Rationale

What is tested? the key is a secret - a properties dump (a startup log, a debug endpoint) must not print it.

How is success determined? toString carries the redaction marker, never the key.

Why does it matter? data-class toString would otherwise leak the secret into every context that prints the bean. Given/When/Then

should reject a blank masking key but accept an empty one

Rationale

What is tested? the binding-time rule - empty means unkeyed, blank is a misconfiguration.

How is success determined? whitespace fails construction naming the property; the empty default binds.

Why does it matter? a whitespace key would silently key the fingerprint with a worthless secret. Given/When/Then

Slow request threshold

should accept exactly one millisecond as the smallest threshold

Rationale

What is tested? the boundary of the threshold guard.

How is success determined? one millisecond is accepted unchanged.

Why does it matter? the logged duration has millisecond resolution, so 1 ms is the smallest threshold that can ever match a logged value. Given/When

should reject a positive threshold below one millisecond

Rationale

What is tested? the resolution floor - the logged duration has millisecond resolution, so a sub-millisecond threshold would flag exchanges whose logged duration reads 0 ms.

How is success determined? construction fails with a message naming the floor.

Why does it matter? a silently accepted 500us threshold escalates all traffic to WARN. Given/When

should reject zero and negative thresholds

Rationale

What is tested? the slow-threshold guard at binding time.

How is success determined? Duration.ZERO and a negative duration both throw IllegalArgumentException.

Why does it matter? a zero threshold would flag every exchange as slow, a negative one none; both are configuration errors better caught at start than read off a dashboard. Given/When/

RequestLoggingWebFilterBodyAndHeaderTest

19 tests.

Header selection and masking

should log selected headers multi-value and mask the configured ones stably

Rationale

What is tested? header selection and masking on the reactive request - a repeated Accept header and a secret header masked by lower-case name.

How is success determined? multi-value joined with a comma, the secret fingerprinted and never verbatim, matching case-insensitive.

Why does it matter? a single-value getFirst would truncate repeated headers, and a case- sensitive mask list would leak a secret under a differently cased name.

should log the selected response header set by the chain

Rationale

What is tested? the response-header selection read at emission from a header the chain set.

How is success determined? endpoint_response_headers carries the Content-Type.

Why does it matter? response headers exist only after the chain ran; reading them earlier would log nothing.

should mask every selected header by default so a wildcard include never leaks plaintext

Rationale

What is tested? ADR-0005 at the filter - includes: ["*"] with nothing said about masking.

How is success determined? every logged request header is a fingerprint; the secret appears nowhere.

Why does it matter? with masking as a second, empty list the same configuration logged everything in plaintext - the unsafe combination was the convenient one. Given

should render masked values through a host-provided masker

Rationale

What is tested? the masker is an injected collaborator - a filter built with a host bean masks request AND response headers with it.

How is success determined? both selected, masked headers carry the host masker's output, never the plaintext and never the built-in fingerprint.

Why does it matter? a compliance regime forbidding unkeyed hashes must be satisfiable without forking the module.

Outcome-gated bodies

should log both bodies of a 4xx response although its outcome stays success

Rationale

What is tested? the gate is wider than the outcome vocabulary by one status class - a 4xx keeps its success outcome (the application answered; the client's request was wrong) but is exactly the case a body explains.

How is success determined? outcome success, and BOTH bodies on the line.

Why does it matter? a validation error\'s response body is the most wanted body of all; hiding it behind the outcome vocabulary would make on-failure useless for client errors. Given/

should log both bodies of a 5xx response in on-failure mode

Rationale

What is tested? on-failure body logging for a failure outcome WITHOUT an error signal - a 502 the handler answered.

How is success determined? outcome failure, request and response body on the line.

Why does it matter? a 5xx is a failure to the operator whether or not an exception was involved; the body gate must follow the outcome, not the signal. Given/

should log the teed request body of an exchange whose handler errored in on-failure mode

Rationale

What is tested? on-failure body logging when the handler consumed the body and then errored, with the emission deferred to the commit.

How is success determined? the exception propagates; the event carries outcome failure and the request body, and no response body.

Why does it matter? the request body flowed before the outcome was known; on-failure must keep it for exactly this line and drop it for a clean one.

should still measure the size of a body it withholds

Rationale

What is tested? on-failure plus request-body measuring on a successful exchange.

How is success determined? the size sample is recorded, the body field is absent.

Why does it matter? the meter and the field are independent opt-ins; a body the mode withholds from the log is still bytes that flowed.

should withhold both bodies from a successful exchange in on-failure mode

Rationale

What is tested? the volume switch - on-failure tees the request body (the outcome is unknown while it flows) and discards both captures at emission when the outcome is success.

How is success determined? the client receives the response body; the line carries neither body.

Why does it matter? this is the mode that keeps body logging affordable outside a debug session. Given/When

Publisher specialization

should hand a Flux body to the delegate as a Flux

Rationale

What is tested? the publisher specialization of the response decorator - a two-buffer Flux written through it.

How is success determined? the delegate receives a Flux (not a Mono) and the capture holds both buffers' bytes.

Why does it matter? servers take a different write path for a single-buffer Mono; a decorator that changed the publisher type would change the server's behaviour, not only the log. Given

should hand a Mono body to the delegate as a Mono so Spring keeps its optimized path

Rationale

What is tested? publisher specialization through the tee - AbstractServerHttpResponse.writeWith has an optimized Mono branch that a Flux-wrapped body would defeat whenever capture is enabled.

How is success determined? a Mono body arrives at the delegate as a Mono, a Flux as a Flux; the capture sees the bytes in both cases.

Why does it matter? single-buffer responses are the common case; capture must not cost them the framework's fast path.

Request body tee

should copy nothing at all in count-only mode while still counting every byte

Rationale

What is tested? the count-only path of the tee (limit 0, the body-size-metrics mode) - its documentation says nothing is buffered, and after the finding-6 fix nothing is COPIED either.

How is success determined? the buffer passes through as the same instance, the count is exact, and the capture holds zero buffered bytes (loggedValue carries only the truncation note).

Why does it matter? measure-only deployments run this path for EVERY body byte of every exchange; an unbounded hidden copy there is the finding's worst-case configuration.

should forward the original buffer untouched and copy only the bounded prefix

Rationale

What is tested? the tee's memory contract - counting never clones the buffer; at most the capture's remaining capacity is copied via a non-advancing read, and the ORIGINAL buffer flows downstream with its read position untouched.

How is success determined? downstream receives the identical buffer instance, fully readable; the capture holds exactly the 8-byte prefix and counted all 16 bytes.

Why does it matter? the old full-buffer copy made transient memory scale with response buffer sizes instead of maxBodyBytes - the bound is the entire point of the cap, and an operator sizing heap by the cap must be able to rely on it.

should log the request body the application actually consumed

Rationale

What is tested? the request-body tee on a chain that subscribes to the decorated body.

How is success determined? endpoint_request_body carries the bytes that flowed.

Why does it matter? the tee mirrors consumption; a decorator the chain could bypass would log an empty body without another symptom.

should omit the request body key when the application never subscribed

Rationale

What is tested? the tee's truthfulness in reactive terms - an unsubscribed body flows nowhere.

How is success determined? no endpoint_request_body key, rather than an empty or fabricated value.

Why does it matter? 'logged' must mean 'actually flowed', identical to the servlet twin's rule. Given/

should tee only the first subscription of a replayable request body

Rationale

What is tested? the subscription-aware tee - a replay-capable request (Flux.just replays) subscribed twice, as a caching filter or a second reader may do.

How is success determined? both subscriptions receive the body, but the capture holds the logical body ONCE - text not duplicated, size not doubled.

Why does it matter? a doubled capture logs "hellohello" and inflates endpoint.request.body.size.

should truncate the logged request body at the capture limit and say so

Rationale

What is tested? a 16-byte body against an 8-byte cap.

How is success determined? the captured prefix plus the explicit truncation note with the total.

Why does it matter? the cap bounds memory, not the exchange; the note is what tells an operator the body was longer than what they see.

Response body tee

should log the response body and deliver identical content downstream

Rationale

What is tested? the response-body tee on a chain writing through the decorated response.

How is success determined? the body is logged AND arrives at the client unchanged.

Why does it matter? a tee, not a cache: a decorator that consumed or altered the buffers would break the response it observes.

Zero-copy boundary

should not advertise zero-copy so file responses fall back through the tee

Rationale

What is tested? the mechanism the capture correctness rests on - writers check the RESPONSE instance for ZeroCopyHttpOutputMessage; because the decorator does not implement it, file-serving handlers fall back to the buffered path and their bytes flow THROUGH the tee.

How is success determined? the decorator is not assignable to the zero-copy interface.

Why does it matter? implementing the interface here would silently re-open a capture bypass - this pin forces that change to revisit the trade-off consciously. Given/When/

RequestLoggingWebFilterIntegrationTest

6 tests.

should capture both bodies of a real reactive round trip

Rationale

What is tested? both tees on real Netty buffers - a POST whose handler reads and echoes the body.

How is success determined? the event carries the request body and the echoed response body.

Why does it matter? pooled Netty buffers differ from the mock DataBuffers; only the real server proves the tee reads them without consuming them. Given/

should hand a real suspend handler the endpoint MDC via the coroutine variant

Rationale

What is tested? the whole coroutine chain against real Netty - the auto-configuration picks the CoRequestLoggingWebFilter (coroutine libs are on this classpath), CoWebFilter publishes the MDCContext-carrying coroutine context, and WebFlux's suspend-handler invocation inherits it.

How is success determined? the suspend handler READS its own MDC and returns the correlation id it saw there - the response body is the proof.

Why does it matter? handler-side correlation in coroutine apps is the coroutine variant's whole reason to exist; only a real runtime proves the context handoff end to end. Given/

should join the event with the caller trace via the real traceparent header

Rationale

What is tested? the traceparent parse on a real request carrying a W3C header.

How is success determined? the event's MDC and message carry the header's trace id.

Why does it matter? the log-to-trace join rests on this id; a header lost or rewritten by the real server would break it silently. Given/

should log one complete event for a real reactive exchange including template headers and bodies

Rationale

What is tested? the full happy path through real Netty - registration by the reactive auto-configuration, WebFlux dispatch, the DataBuffer tee on pooled buffers, the recorded handler pattern, and the correlation echo.

How is success determined? correct response AND one INFO event, format-identical to the servlet twin.

Why does it matter? only a real reactive runtime proves the buffer handling (release/rewrap) and the attribute names hold outside the mocks. Given/

should log the rendered 500 for an unhandled handler error via the commit-deferred emission

Rationale

What is tested? the commit-deferred error path against Boot's REAL error handler - the error signal passes the filter before rendering, the emission must wait for the commit.

How is success determined? the client sees 500 and the single ERROR event carries status 500 (not a stale pre-rendering value), outcome failure, and the original cause; the correlation echo survives onto the error response.

Why does it matter? this is the reactive twin of the servlet module's request-destruction fix - the semantics the two stacks must share. Given/

should not log an excluded path while still logging the next regular exchange

Rationale

What is tested? the exclude prefix on the real server, followed by a regular request.

How is success determined? both served with 200; exactly one event, for the regular path.

Why does it matter? an exclusion must not leave state behind that suppresses or duplicates the next exchange on the same server. Given/

RequestLoggingWebFilterMetricsTest

20 tests.

Body size distributions

should record body sizes under the handler pattern independent of the level gate

Rationale

What is tested? the response-size sample with the exchange logger gated to ERROR and a handler pattern recorded.

How is success determined? the summary under the template tag holds the 4 bytes although no event was emitted.

Why does it matter? a metric must not appear and disappear with the logger level; the size is recorded before the gate.

Counters and gauge

should keep the open-exchanges gauge up while an error waits for its commit

Rationale

What is tested? the gauge as the liveness signal of the commit-deferred error path.

How is success determined? after the error signal the gauge still reads 1 (the exchange is awaiting the rendered status); after the commit it reads 0 and the event exists.

Why does it matter? an error whose commit never happens must stay VISIBLE - the gauge baseline is the only signal for that silent-loss mode, exactly as in the servlet twin.

should pre-register the reactive outcome vocabulary and count emitted events per outcome

Rationale

What is tested? the reactive events counter carries the cancelled outcome the servlet twin does not have, pre-registered like everything else.

How is success determined? success/failure/cancelled exist at zero; a success and a cancellation count one each on their side.

Why does it matter? the reconciliation ground truth must cover every disposition this stack emits.

should share one metrics owner between two filters on the same registry

Rationale

What is tested? the per-registry metrics ownership - a second filter wired against the SAME registry must observe through the shared owner, not through a duplicate instance whose gauge registration Micrometer would silently ignore.

How is success determined? an exchange handled by the SECOND filter moves the registry's open-exchanges gauge to 1 mid-flight and back to 0 at completion.

Why does it matter? with a duplicate owner the second filter's live exchanges were invisible on the gauge - exactly the wiring a host reaches by constructing filters manually.

Fail-open stages

should confine a commit-callback failure instead of disturbing the response commit

Rationale

What is tested? the beforeCommit guard - the callback runs INSIDE the response-commit chain, where an escaping exception would disturb the commit itself.

How is success determined? with a status read that throws at commit time, setComplete() completes normally, the deferred event is lost but COUNTED as an emission failure.

Why does it matter? disturbing the commit is the one outcome the fail-open contract forbids.

should confine a terminal-callback failure and still emit the event

Rationale

What is tested? the doFinally guard - bookkeeping there is fallible (attribute access, breadcrumb), and an escaping exception would be rethrown into Reactor's signal propagation.

How is success determined? with an attributes map that throws, nothing propagates, wiring=1, and the event is STILL emitted - a broken breadcrumb costs detail, never the event.

Why does it matter? this callback runs for every exchange; it must be as fail-open as the rest.

should confine an arrival-line backend failure and count stage arrival

Rationale

What is tested? the arrival guard's coverage - the logger-level gate and MDC-scope construction are backend calls and must sit INSIDE the fail-open guard; before the fix an exception from the level lookup escaped logRequestStart and failed the request during filter assembly.

How is success determined? with a logging backend whose level check throws (a throwing TurboFilter - logback consults turbo filters inside isInfoEnabled), the request is served untouched and the loss is counted as stage arrival.

Why does it matter? the arrival line is OPTIONAL observability; it failing the exchange would invert the module's central contract.

should count a broken emission as stage emission and never disturb the exchange

Rationale

What is tested? the emission guard covers the whole emission including its pre-gate section - here the injected time source throws on its emission-time read.

How is success determined? no exception surfaces, no event, emission=1.

Why does it matter? the emission counter is the metric channel for exactly this loss.

should degrade to a pass-through and count stage wiring when a host bean throws

Rationale

What is tested? the wiring fail-open contract, identical to the servlet twin.

How is success determined? the chain still runs, nothing propagates, wiring=1, no event.

Why does it matter? a logging component must never fail the request it describes.

should keep logging the exchange when the correlation-source counter throws at wiring

Rationale

What is tested? the isolation of an OPERATIONAL counter from the exchange it observes - registration succeeds, the increment throws, and everything else is healthy.

How is success determined? the event is emitted as usual; the counter failure is counted stage=wiring; nothing propagates.

Why does it matter? unguarded, the throw aborted the wiring and degraded the request to an unlogged pass-through - one broken metric suppressed viable exchange logging.

should keep serving and logging when the host registry already owns an endpoint meter of another type

Rationale

What is tested? fail-open meter registration - Micrometer rejects an id that exists with a different type.

How is success determined? the filter constructs against a registry that pre-registered the fail-open meter as a GAUGE and the body-size meter as a COUNTER; the exchange runs and its event is emitted; the conflicting meters stay private (the host's meters are untouched).

Why does it matter? unguarded, the construction throw aborts the application context and the lazy body-size throw suppresses the whole exchange event - a logging library must do neither.

should leave the exchange open when an earlier commit action fails the commit

Rationale

What is tested? the residual boundary of the late-registered commit callback - Spring concatenates the actions, so an action registered by the chain that FAILS stops the sequence before the module's callback runs.

How is success determined? the commit attempt fails, no event is emitted, and the exchange stays on the open-exchanges gauge - the documented never-commits semantics - instead of logging a status the client never received.

Why does it matter? the gauge is the liveness signal for this loss mode; an early callback would have logged a "committed" status for a commit that then failed.

should not report an emitted event as an emission failure when the events counter throws

Rationale

What is tested? the post-log() increment of the events counter - the line is already on the logger when it throws.

How is success determined? the event is on the appender, stage=emission stays 0 (the event was NOT lost) and the counter failure lands on stage=wiring.

Why does it matter? the fail-open and events meters are the reconciliation signals for log-pipeline loss; a false "emission failed" for an emitted line makes them lie.

should route a synchronous downstream throw into the deferred error path instead of leaking the gauge

Rationale

What is tested? a downstream WebFilter that THROWS while assembling its publisher instead of returning Mono.error - before the fix the exception bypassed doOnError/doFinally entirely, lost the event, and left the open-exchange gauge permanently inflated.

How is success determined? the throw surfaces as the pipeline's error signal (block() throws it), the exchange stays OPEN awaiting the rendered status, and the commit then closes the gauge and emits exactly one ERROR event - byte-identical semantics to the Mono.error path.

Why does it matter? repeated occurrences of the leak would make the liveness gauge report a false logging-pipeline outage, and each occurrence silently lost an exchange event.

should stay fail-open when the commit-callback registration itself throws

Rationale

What is tested? the registration half of the commit-callback contract - beforeCommit runs against a possibly host-provided response facade, and before the fix a throw there failed the request AFTER the gauge was incremented. The registration happens at the ERROR signal, so an erroring chain is what exercises it.

How is success determined? nothing propagates beyond the chain's own error, the failure is counted as stage wiring, and the exchange STILL completes at the terminal signal instead of deferring to a callback that will never run - event emitted, gauge back at zero.

Why does it matter? this was the one wiring step outside the fail-open boundary; a logging component must never fail the request it describes.

should still serve the request when the fail-open diagnostics themselves throw

Rationale

What is tested? the secondary guard around the catch handlers' diagnostics - the wiring fails AND the fail-open counter's increment throws.

How is success determined? the chain runs and nothing propagates out of the filter.

Why does it matter? a throw escaping a catch handler before the chain fails request assembly - the one outcome the fail-open contract forbids.

Request body read counter

should count a body the application cancelled mid-stream as partial

Rationale

What is tested? the tee observes the application's subscription only - a take(1) over a two-chunk body cancels upstream, so no completion signal ever arrives.

How is success determined? partial, with only the first chunk in the size sample.

Why does it matter? the reactive counterpart of a parser bailing out early; a tee that drained the rest to find out would alter the request's backpressure.

should count a body the application never subscribed to as unread

Rationale

What is tested? the distinction neither the logged body nor the size sample can make - a body that was SENT but never READ.

How is success determined? unread counted, no size sample.

Why does it matter? an endpoint silently ignoring its payload looks identical to a bodyless request in every other signal of this module.

should count a fully consumed body as complete under the handler pattern

Rationale

What is tested? the read-state counter for a chain that drains a two-buffer request body.

How is success determined? one complete under the template, zero partial.

Why does it matter? the counter's complete share is what tells consumed bodies from ignored ones per route.

should record nothing when request body measuring is off

Rationale

What is tested? the default filter without request measuring on a chain that reads the body.

How is success determined? no read-state counter exists in the registry.

Why does it matter? the measuring flag is the opt-in; a counter appearing without it would surprise a host that budgets its meter cardinality.

RequestLoggingWebFilterTest

23 tests.

Exclusions and start line

should announce the exchange before the chain when enabled

Rationale

What is tested? the optional arrival line - its timing relative to the chain and its content.

How is success determined? the start line is visible while the chain runs, carries the identity in the MDC and no outcome; the completion line carries the outcome.

Why does it matter? long-running exchanges are visible in flight only with the start line, and outcome-keyed dashboards must stay blind to it.

should be active only for endpoints matching an include pattern

Rationale

What is tested? the include pattern on the reactive filter - one matching and one non-matching exchange.

How is success determined? only the matching exchange is logged and carries the correlation echo; the other has no echo header.

Why does it matter? activation is identical on both stacks by construction; an include that leaked the echo onto non-matching responses would touch traffic it is not supposed to see.

should let an exclude win inside an included pattern

Rationale

What is tested? an exclude prefix nested inside an include pattern.

How is success determined? the excluded-inside-included exchange produces no event.

Why does it matter? an exclude always wins - the rule the header sections follow too; a different precedence here would surprise an operator configuring both.

should match activation against the path within the application under a base path

Rationale

What is tested? finding 3 of the repo-wide code analysis of 2026-08-30 - activation must match what the WebFlux handler mapping matches, the path WITHIN the application, not the full request path that includes a configured base path.

How is success determined? under base path /app, /app/api/things is logged by include /api/** and /app/actuator/health is excluded by /actuator/health; matching against the full path would silently invert both.

Why does it matter? on a non-root deployment, include patterns modeled after the routes otherwise match nothing - total, silent loss of exchange logging exactly where the operator configured it.

should match activation on the raw request path exactly as the WebFlux router does

Rationale

What is tested? twin parity with the servlet module's percent-encoding fix - activation must see the request target the way the router does: the raw RequestPath, segments decoded for matching, once.

How is success determined? /%61pi/things is included (router serves it under /api/**) and logs the raw path; /api%2Fthings is NOT included (the router sees one segment "api/things" and would not serve it) - the old decoded-then-reparsed path decoded it twice and accepted it; /%61ctuator/health is excluded.

Why does it matter? activation and routing disagreeing means exchanges logged that were never served, or - in the other direction - served exchanges missing from the log.

should not log an excluded path at all

Rationale

What is tested? the exclude prefix on the reactive filter for a request below it.

How is success determined? the chain runs, nothing is logged.

Why does it matter? health probes and the like must produce no line at all, not a suppressed one that still costs wiring.

should reject an invalid include pattern at construction time

Rationale

What is tested? a syntactically broken PathPattern in the include list.

How is success determined? the constructor throws the parser's PatternParseException whose detail names the malformed pattern.

Why does it matter? a configuration error must fail the context start with a diagnostic, not fail per request or match nothing silently. Given/

Levels and outcomes

should compare the slow threshold at full precision instead of truncated milliseconds

Rationale

What is tested? the threshold comparison - a 1.5 ms threshold used to truncate to 1 ms and flag a 1 ms exchange.

How is success determined? 1.0 ms is NOT slow, 1.5 ms IS slow, under a 1.5 ms threshold.

Why does it matter? truncating both sides inflated WARN logs for every threshold with sub-millisecond precision.

should defer the error emission until the response commits with the rendered status

Rationale

What is tested? the commit-deferred error path - the reactive analog of the servlet twin's request-destruction emission.

How is success determined? after the error signal NOTHING is logged (only the breadcrumb on the module logger); once the upstream handler renders and the response commits, exactly one ERROR event carries the FINAL 500 and the original cause; the error itself propagates unchanged.

Why does it matter? emitting at the error signal would log the pre-rendering status - the exact wart the servlet twin eliminated; the deferral is what keeps the twins' semantics equal.

should escalate to WARN and flag a slow but successful exchange

Rationale

What is tested? the slow threshold reached by a successful exchange.

How is success determined? WARN, endpoint_slow true, outcome still success.

Why does it matter? slowness raises severity; it must never turn a completed exchange into a failure on the outcome dashboards.

should escalate to WARN with outcome failure for a handled 5xx

Rationale

What is tested? the classification of a chain that answers 503 without an error signal.

How is success determined? WARN with outcome failure.

Why does it matter? severity and semantic are decoupled: the handler answered, so WARN rather than ERROR, while the outcome tag still counts the failure.

should log outcome cancelled with a dash status when the client disconnects

Rationale

What is tested? the reactive disposition the servlet twin does not have - a cancelled subscription (client disconnect), typically without a committed response.

How is success determined? one WARN event, outcome cancelled, message shows '-> -' and the status field is absent rather than invented.

Why does it matter? without the flag a torn-down exchange would log as a success; without the dash it would log a status the client never saw.

should observe status and header mutations of later commit actions on the deferred error path

Rationale

What is tested? the commit-action ORDERING of the deferred error path - Spring runs beforeCommit actions in registration order, so the module's callback must be registered AFTER the chain ran, behind every action a downstream filter registered (security/session header writers, a status mutation).

How is success determined? a downstream filter registers an action that turns the rendered 500 into a 503 and adds a selected response header; the single ERROR event carries 503 and that header - exactly what the response applies - not the pre-action 500.

Why does it matter? registered at filter entry, the callback ran FIRST and logged a status the client never received and a header set that was not yet complete.

The exchange line

should carry the traceparent-derived trace context into the event

Rationale

What is tested? an incoming W3C traceparent header - the trace id and the caller's span id at emission.

How is success determined? traceId and parentSpanId in the MDC and the message; no spanId key.

Why does it matter? the header's parent-id is the CALLER's span; publishing it as the local spanId would misattribute the server span this module cannot know.

should echo the correlation id and adopt one from the request header on a traceless exchange

Rationale

What is tested? a traceless request already carrying a correlation id.

How is success determined? the id is echoed on the response, rides the event's MDC and appears inline in the message.

Why does it matter? ADR-0002 on the inbound side: a traceless caller's private correlation is honoured, not replaced.

should fall back to the correlation contract when the traceparent is not conformant

Rationale

What is tested? an invalid traceparent counts as ABSENT (ADR-0002) - the traceless contract applies in full: the correlation header is accepted and echoed.

How is success determined? the event's request id is the caller's correlation id, the echo header is present, and no trace decoration is emitted.

Why does it matter? half-trusting a malformed header would mint a request id from bytes the W3C validation rejected - the strict parser is the single gate for both the trace fields and the identity decision.

should log query and handler pattern as their own fields

Rationale

What is tested? the path pair on the reactive stack - expanded path, query string and the WebFlux handler pattern each in its own field.

How is success determined? endpoint_url_path, endpoint_url_query and endpoint_url_template carry their values separately.

Why does it matter? grouping by path must not be defeated by varying queries, and the template is the low-cardinality half; the servlet twin logs the same three fields.

should log the identical line format of the servlet twin

Rationale

What is tested? the format contract - message and endpoint_* key-values must be indistinguishable from limesium-servlet-logging's output.

How is success determined? the exact message string and the full field family (minus endpoint_async, which the reactive twin deliberately never emits).

Why does it matter? identical logging is this module's core requirement - dashboards must not care which stack produced an event.

should log the raw request target so percent-encoded control characters cannot forge log lines

Rationale

What is tested? the log-injection guard for the raw request target - java.net.URI decodes getPath()/getQuery(), so %0A in the request target used to become a real line break in the message, the MDC route and the fields.

How is success determined? path and query appear percent-encoded as sent in the message, in the endpoint_url_path/endpoint_url_query fields and in the endpoint_route MDC entry; no sink contains a line break.

Why does it matter? an unauthenticated client could otherwise forge complete exchange lines in every plain-text appender, and - via the Reactor context - in every handler log line.

should not adopt a stale trace context of the emitting thread when none was parsed

Rationale

What is tested? the emission scope's OWNERSHIP of the trace keys (twin parity with the servlet emitter) - an id that was not parsed from the request must be absent from the event even when the emitting thread carries one, as it does under spring.reactor.context-propagation=auto with a tracing bridge.

How is success determined? the event has no traceId, parentSpanId or spanId and no trace suffix; the thread's values are back in place afterwards (owned for the scope only).

Why does it matter? a bridge's live or stale ids would join a traceless exchange to a trace that is not its request id - the inconsistency ADR-0002 rules out (code analysis of 2026-09-05, finding 1).

should suppress a bridge spanId beside the parsed trace pair on the exchange line and the arrival line

Rationale

What is tested? the bridge's local-span key is suppressed during BOTH emissions of a traced exchange - the module never publishes under spanId (ADR-0002) - and restored afterwards.

How is success determined? arrival and completion event carry the parsed traceId/parentSpanId and no spanId; the thread's bridge value survives the exchange.

Why does it matter? under automatic propagation the bridge's server-span id sits live in the MDC around the emission; publishing it would read as if this module had measured that span.

should treat an over-long or non-token correlation header value as absent and generate instead

Rationale

What is tested? the acceptance rule for caller-supplied ids (CorrelationHeaderValue) at the reactive filter - a value beyond 128 characters and a value with an inner space.

How is success determined? both exchanges carry the generated id in event and echo; the caller's value appears nowhere on the line.

Why does it matter? an accepted value is written into every log line, the MDC and the Reactor context of the exchange; without the bound the peer dictates log volume and id shape (code analysis of 2026-09-05, finding 11).

should use the traceparent trace id as the request id and suppress the echo

Rationale

What is tested? the identity decision of ADR-0002 - a conformant traceparent's trace id IS the request id, a caller-supplied X-Correlation-Id is ignored, and NO X-Correlation-Id response header is written.

How is success determined? endpoint_request_id equals the trace id in MDC and message; the response carries no correlation header although the request supplied one.

Why does it matter? a request logger must be observationally neutral - on a traced exchange the wire already carries the identity, and echoing a second, private id would make enabling the logger visible in the communication.

RequestLoggingWebFilterTracingAutoPropagationIntegrationTest

3 tests.

should keep the ownership on the commit-deferred error path under automatic propagation

Rationale

What is tested? the deferred emission runs inside the response-commit chain, subscribed from Boot's error renderer under the observation filter's context - the bridge MDC is live there too.

How is success determined? the 500 event carries the caller's trace id and parent, no spanId.

Why does it matter? the error path is the one place the emission leaves the terminal signal's operator; ownership must hold on that thread as well. Given/

should log no trace context for a traceless exchange although the bridge's trace is live around the emission

Rationale

What is tested? the no-traceparent boundary under auto - the bridge mints a trace of its own and its traceId/spanId are live in the MDC when the emission runs.

How is success determined? the bridge reports a well-formed trace id, yet the event carries neither traceId nor parentSpanId nor spanId and no trace suffix; the request id is the generated correlation id and is echoed.

Why does it matter? without ownership the event would carry a traceId that is NOT its request id - the exact inconsistency ADR-0002 rules out (the trace id IS the request id, or there is none). Given/

should publish the parsed pair and never the bridge's live spanId under automatic propagation

Rationale

What is tested? the emission scope's ownership of the trace keys against a bridge MDC that is LIVE around the terminal callback - under auto the ObservationThreadLocalAccessor restores the server span's traceId/spanId around every operator, doFinally included.

How is success determined? the bridge continued the caller's trace under a span of its own; the event carries the caller's trace id (as traceId and as request id) and the caller's parent-id as parentSpanId, and NO spanId key although the bridge's local span id sat in the MDC.

Why does it matter? under the module's own recommended propagation mode the reactive twin used to inherit whatever the bridge had put there; a local span id on the event contradicts ADR-0002 and the servlet twin, and dashboards keying on its absence would see stack-dependent data. Given/

RequestLoggingWebFilterTracingIntegrationTest

3 tests.

should keep the join on the commit-deferred error path under a real bridge

Rationale

What is tested? the trace context survives the deferred emission - the event is written from the commit callback, on whatever thread commits the error response, not from the filter.

How is success determined? the 500 event still carries the caller's trace id and parent.

Why does it matter? the trace ids live on the Exchange, captured at wiring; the error path is the one place the emission leaves the terminal signal's thread. Given/

should log no trace context without a caller traceparent although the bridge traces the exchange

Rationale

What is tested? the documented boundary of the header-based design - a trace the bridge mints itself (no incoming context) is NOT joined, because the event-loop thread carries no bridge MDC at filter time and the module deliberately avoids an observation-context dependency.

How is success determined? the bridge reports a well-formed trace id for the exchange, yet the event carries neither traceId nor parentSpanId in its MDC and no trace suffix in the message.

Why does it matter? this is a conscious limitation; pinning it makes a future change of that decision deliberate and visible in both twins. Given/

should log the trace id the real server span runs under when the caller sends a traceparent

Rationale

What is tested? the log-to-trace join under a real bridge - the module's traceId comes from the header, the bridge's server span comes from Boot's W3C propagation of the same header; the two must agree, or every "open the trace from the log line" link would be dead.

How is success determined? the bridge's trace id (read by the handler from the observation context) equals the header's and the event's MDC traceId; the event carries the header's parent-id as parentSpanId; the server span's own id differs from the parent; and the event does not publish the parent under spanId.

Why does it matter? the join is the design's justification for parsing the header at all, and it rests on the bridge's propagation policy - a real bridge, not a mock, must confirm it. Given/

TomcatServerIntegrationTest

7 tests.

should log a real round trip with both bodies through this server

should log the rendered 500 via the commit-deferred emission on this server

should log the status and header a later commit action applies on this server's error response

should pin that a form body WebFlux parses bypasses the request tee on this server

should record the handler pattern of a real dispatch on this server

should run on this server

should run the reactor variant as the single active filter on this server

TwinContractTest

6 tests.

should pin the MDC keys to the literal twin contract

Rationale

What is tested? the MdcKeys and TraceMdcKeys literals of the reactive stack - including its parentSpanId key.

How is success determined? the three endpoint_* keys, traceId and parentSpanId spelled exactly so.

Why does it matter? structured encoders emit MDC entries as fields by name; a drift would break the join between the two twins' lines and the trace. Given/When/

should pin the exchange and arrival message format to the literal twin contract

Rationale

What is tested? the MESSAGE half of the twin contract - the field names are locked by EndpointLogFieldTest, the message text was asserted by KDoc only (pinned in both twins).

How is success determined? a pinned exchange renders the literal messages both twins ship.

Why does it matter? plain-text appenders and the README's parity promise key on this text; a divergence in one twin would otherwise ship silently.

should pin the masking fingerprint format to the literal twin contract

Rationale

What is tested? HeaderValueMasker.DEFAULT over a fixed value - the length:hex shape with the first 64 bits of SHA-256.

How is success determined? secret-token renders as the literal 12:930bbdc51b6aed5c.

Why does it matter? a masked token must correlate across both twins and the outbound sibling Legatium, which pins the same literal. The expected value is hardcoded, not derived: the first 64 bits of SHA-256 over the UTF-8 bytes are stable across JVMs - and a format change in one twin breaks that module's literal here, forcing coordinated change. Given/When/

should pin the meter names to the literal twin contract

Rationale

What is tested? the duplicated meter-name constants, spelled out as literals - the cross-module lockstep tests cover configuration and field names, but not these.

How is success determined? every meter name matches the literal both twins ship.

Why does it matter? a renamed meter in ONE twin would split every dashboard by stack - silently. Given/When/

should pin the request body read states to the literal twin contract

Rationale

What is tested? the state tag vocabulary of endpoint.request.body.read, which both twins derive from their own BodyReadState enum.

How is success determined? the three tag values match the literals both twins ship.

Why does it matter? a dashboard splitting by state must not see complete from one stack and completed from the other. Given/When/

should pin the shared outcome vocabulary plus this stack's own disposition

Rationale

What is tested? the outcome literals of the reactive stack - the shared success and failure plus cancelled.

How is success determined? the three literals match the values dashboards filter on.

Why does it matter? endpoint_outcome and the events counter's tag are the closed vocabulary every alert keys on; a renamed value would silently zero an alert. Given/When/

limesium-servlet-logging

191 tests.

BoundedBodyCaptureFuzzTest

9 tests.

capture_upholds_its_contract(FuzzedDataProvider)[1]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[2]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[3]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[4]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[5]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[6]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[7]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[8]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

capture_upholds_its_contract(FuzzedDataProvider)[9]

Rationale

What is tested? BoundedBodyCapture under a fuzzed sequence of single-byte and ranged captures, start and completion marks and clears, against a fuzzed cap.

How is success determined? totalBytes equals the bytes fed since the last clear; the logged value is null exactly for zero bytes; a body beyond the cap carries the truncation note - for any charset and any input Jazzer generates.

Why does it matter? the capture bounds memory on every request; a counting drift or a missing note would misreport payload sizes and truncation to the operator without another symptom.

BoundedBodyCaptureTest

7 tests.

Read state

should move to partial on start and to complete on completion, never backwards

Rationale

What is tested? the read-state transitions - partial on markStarted, complete on markCompleted, and a later markStarted.

How is success determined? PARTIAL, then COMPLETE, and COMPLETE again after the late start.

Why does it matter? the state is the state tag of the read counter; a second getInputStream call regressing complete to partial would count a fully read body as abandoned.

should report a zero-byte body read to its end as complete

Rationale

What is tested? completeness is independent of the byte count - an empty body can be consumed completely.

How is success determined? COMPLETE with totalBytes 0 and no logged value.

Why does it matter? "complete" and "absent" are different answers to different questions; the size sample collapses them, the read state must not.

should start unread and stay unread while bytes are only counted

Rationale

What is tested? the read state is a fact of its own, not derived from the byte count.

How is success determined? a fresh capture is UNREAD, and captured bytes alone do not move it.

Why does it matter? the response side feeds the same class without ever marking - its state must not drift into PARTIAL just because bytes flowed.

Truncation at a character boundary

should drop an incomplete trailing UTF-8 sequence instead of decoding a replacement character

Rationale

What is tested? byte-bounded truncation of multi-byte text - the cap counts bytes, so it can split a character.

How is success determined? with a 2-byte cap over "h\u00e9" (3 bytes: 68 c3 a9) the logged prefix is "h", not "h\uFFFD"; the byte count stays exact.

Why does it matter? a replacement character in the logged prefix is corruption the reader cannot distinguish from corrupt input.

should drop an incomplete trailing sequence of another variable-width charset

Rationale

What is tested? decodeTruncated on a Shift_JIS body cut inside a two-byte character.

How is success determined? the complete character survives, the cut one is dropped, the truncation note counts all three bytes.

Why does it matter? the capture limit bounds bytes, not characters, for every charset; a decoder that only handled UTF-8 would corrupt the logged prefix for Japanese payloads.

should keep a complete multi-byte character that ends exactly at the cap

Rationale

What is tested? the boundary case of the byte cap - a two-byte character whose last byte is the last captured byte.

How is success determined? the character is logged intact, followed by the truncation note.

Why does it matter? an off-by-one in the underflow handling would drop a character that was fully captured.

should still replace malformed bytes inside the prefix

Rationale

What is tested? a lone continuation byte in the middle of a truncated capture.

How is success determined? the malformed byte renders as the replacement character, the rest of the prefix is intact.

Why does it matter? endOfInput=false must only spare the TRAILING incomplete sequence; malformed bytes inside the prefix are the caller's problem and must stay visible as such.

EndpointLoggingReferenceConfigTest

3 tests.

should bind the body modes by their kebab-case names and refuse the former booleans

Rationale

What is tested? the documented spellings never / on-failure / always bind (Boot's lenient enum conversion), and a leftover true from the boolean era fails the binding loudly.

How is success determined? the two modes bound; true raises a BindException.

Why does it matter? a silently ignored true would switch body logging OFF for an operator who believed it on - the migration must be visible at startup. Given

should bind the reference configuration to exactly the built-in defaults

Rationale

What is tested? that every VALUE in the reference YAML is the built-in default.

How is success determined? binding the file yields an object equal to RequestLoggingProperties() - the data-class equality covers every property at once.

Why does it matter? the reference promises "copy it, and nothing changes"; a drifted default would silently break that promise for everyone who copies the block. Given/

should document only keys that actually exist

Rationale

What is tested? that the reference contains no stale or misspelled keys - the Binder silently IGNORES unknown keys, so the equality test above cannot catch a typo on its own.

How is success determined? every endpoint-logging.* key in the YAML is one of the known property names.

Why does it matter? a documented key that does not bind is worse than an undocumented one - readers copy it and believe it works.

EndpointMdcCallableInterceptorTest

3 tests.

should confine a restoration failure and still remove the other keys

Rationale

What is tested? the fail-open branch of postProcess - the adapter refuses to remove the first key at restoration time.

How is success determined? postProcess does not throw, the remaining keys are removed anyway (best-effort restoration of MdcScope), the loss is counted stage=wiring.

Why does it matter? a restoration that gave up at the first failure would leave module-owned keys on the pooled worker for every later key.

should let the task run without identity and count stage wiring when the install fails

Rationale

What is tested? the fail-open branch of preProcess - the adapter throws on the third key.

How is success determined? neither preProcess nor postProcess throws, the partially installed keys are rolled back (nothing of the identity remains on the worker), wiring reads 1.

Why does it matter? MDC trouble must never disturb async dispatch; a throw from preProcess would fail the Callable before it ran and blame the application.

should overlay the identity on the worker and restore the worker's own MDC afterwards

Rationale

What is tested? the additive overlay-and-restore contract on the worker thread - preProcess installs the three endpoint_* keys over the worker's ambient MDC, postProcess restores it.

How is success determined? between the two calls the identity and the ambient entry are both visible; after postProcess the ambient entry is intact and no endpoint_* key remains; nothing counted.

Why does it matter? the worker is a pooled executor thread - a leaked key would attach this exchange's identity to whatever Callable that thread runs next.

ExchangeLogEmitterTest

9 tests.

Gates and guards

should emit exactly once however often completion is signalled

Rationale

What is tested? two completion signals for one exchange.

How is success determined? one event, one count.

Why does it matter? the containers signal destruction on differing schedules (once-late, per-dispatch); the exactly-once guard is what keeps the line count truthful. Given/

should skip the event and the emitted counter when the level is disabled

Rationale

What is tested? the level gate with INFO disabled on the exchange logger.

How is success determined? nothing logged and the success counter stays at 0.

Why does it matter? the counter counts EMITTED events, so a gated exchange must not be counted, or the reconciliation against the index would show phantom loss.

Level and outcome matrix

should classify a clean 200 as INFO success

Rationale

What is tested? the emitter's classification of a completed exchange without failure - level, outcome, status field and the events counter.

How is success determined? one INFO event with outcome success and status 200; the success counter reads 1.

Why does it matter? the events counter is the reconciliation ground truth against the log index; it must count exactly the events that were emitted. Given/

should classify a handled 5xx as WARN failure without a cause

Rationale

What is tested? an exchange the application answered with 503 and no exception.

How is success determined? WARN, outcome failure, no throwable attached.

Why does it matter? the handler decided the status; ERROR with a stack trace would be noise, while the outcome tag still counts the failure. Given/

should classify a thrown chain as ERROR failure carrying the cause

Rationale

What is tested? an exchange whose chain failed with an exception recorded on it.

How is success determined? ERROR, outcome failure, the exception attached as the event's cause.

Why does it matter? the cause on the event is what a structured encoder renders as the stack trace; without it the failure line names no reason.

should classify an async onError without a throwable as ERROR failure

Rationale

What is tested? callback-true classification - the servlet API permits onError WITHOUT a throwable; the disposition, not the throwable, carries the outcome.

How is success determined? the event is an ERROR-level failure with no attached throwable.

Why does it matter? keying the classification on the throwable's presence would report such an async failure as a success - the container signaled ERROR, and the event must say so even without a cause to attach.

should classify an async timeout as WARN timeout and let it win over a later onError

Rationale

What is tested? the disposition's built-in precedence - TIMED_OUT is the callback that ended the exchange; the container's subsequent onError must not reclassify it.

How is success determined? disposition TIMED_OUT, WARN, outcome timeout, the later throwable as cause.

Why does it matter? the precedence used to live in the emitter's when-order; it is now a property of the value and must hold regardless of evaluation order.

should escalate a slow success to WARN without changing the outcome

Rationale

What is tested? exactly the configured threshold elapsed on a clean exchange.

How is success determined? WARN, endpoint_slow true, outcome success, duration 200 ms.

Why does it matter? slowness raises severity, never the outcome; the boundary must be inclusive so a 200 ms threshold flags a 200 ms exchange.

should keep the timeout precedence when onTimeout and onError race on two threads

Rationale

What is tested? the ATOMICITY of the disposition precedence - the sequential test above proves the ORDER rule, this one the rule under real contention: the container does not confine the two callbacks to one thread, and a volatile check-then-set in onError could read NONE, lose the race to onTimeout and then overwrite TIMED_OUT with ERRORED.

How is success determined? across many exchanges, each with both callbacks released from a shared start latch onto two worker threads, EVERY exchange ends TIMED_OUT; a worker failure propagates via Future.get; the emitted event of the last exchange says timeout.

Why does it matter? a CAS makes the invariant hold deterministically; the old check-then-set held only by luck, which a fixed-order test cannot distinguish from correctness.

HandlerMappingAttributeTest

1 tests.

should mirror the best-matching-pattern attribute name Spring MVC actually uses

Rationale

What is tested? the mirrored attribute-name literal against Spring MVC's own HandlerMapping constant.

How is success determined? the two strings are equal.

Why does it matter? the filter reads the pattern by attribute name; a Spring MVC rename would silently drop endpoint_url_template from every event without this pin. Given/When/

RequestLoggingAutoConfigurationTest

8 tests.

should back off entirely when disabled by property

Rationale

What is tested? endpoint-logging.enabled=false on the servlet stack.

How is success determined? no registrations, no filter, no defaults, no properties bean.

Why does it matter? the master switch must remove the servlet registrations too, or a disabled module would still sit in the container's filter chain. Given/

should bind the properties namespace

Rationale

What is tested? eight endpoint-logging.* keys bound into the properties class, including the nested header section and a duration.

How is success determined? every configured value comes back typed - the enum, the list, the duration in milliseconds.

Why does it matter? the namespace is the operator's contract; a key that silently failed to bind would leave a default in force the operator believes changed. Given/

should key the default masker from the masking-key property

Rationale

What is tested? the property path to a guess-proof fingerprint - no host bean needed.

How is success determined? with masking-key set, the masker bean renders the keyed fingerprint, not the unkeyed default.

Why does it matter? keying is the documented answer to "masked is not a security boundary for guessable values"; it must be reachable from application.yml alone. Given/When

should let a host HeaderValueMasker back the default masker off

Rationale

What is tested? the masker is a @ConditionalOnMissingBean bean like the time source and the id generator - a host pins its own rendering (a keyed HMAC, a fixed ***) once.

How is success determined? with a host bean the context holds exactly one HeaderValueMasker, the host's, and the module's default backed off.

Why does it matter? the properties decide which values are masked; the bean is the host's only handle on HOW - it must win over the default in the shipped configuration. Given/

should let a host-defined time source win over the default

Rationale

What is tested? a host-defined NanoTimeSource bean beside the auto-configuration.

How is success determined? the host bean is the only one; the default backed off.

Why does it matter? the time source is an injectable seam for tests and hosts; two beans would make the injection ambiguous at start. Given/

should register the fail-open counters in a host-provided meter registry

Rationale

What is tested? the filter bean created against a host MeterRegistry.

How is success determined? all three fail-open stages are pre-registered there.

Why does it matter? a rate() alert must see the zero before the first occurrence, and in the HOST registry, where it is exported.

should register the filter its completion listener and the defaults in a servlet web application

Rationale

What is tested? the auto-configuration alone in a servlet web context.

How is success determined? one filter, one filter registration wrapping it, one listener registration carrying the emission, and the three injectable defaults.

Why does it matter? the emission lives in the completion listener, not the filter; a missing listener registration would wire a filter that never logs. Given/

should wire a host-defined filter into the registration instead of creating a second one

Rationale

What is tested? a host-defined RequestLoggingFilter bean beside the auto-configuration.

How is success determined? one filter - the host's - wrapped by the registration, with the completion listener still present.

Why does it matter? overriding the filter must change the filter and keep the wiring; a second filter would log every exchange twice. Given/

RequestLoggingFailOpenCounterTest

12 tests.

Arrival stage

should confine an arrival-line backend failure and count stage arrival

Rationale

What is tested? the arrival guard's coverage - the logger's level gate is a call into the host's logging backend and must sit INSIDE the fail-open guard; before the fix an exception from isInfoEnabled escaped logRequestStart and failed the request before the chain ran.

How is success determined? with a logging backend whose level check throws (a throwing TurboFilter - logback consults turbo filters inside isInfoEnabled), the request is served untouched and the loss is counted as stage arrival; the completion emission hits the same broken backend and is confined by ITS guard as an emission loss.

Why does it matter? the arrival line is OPTIONAL observability; it failing the exchange would invert the module's central fail-open contract.

Emission stage

should count a broken emission as stage emission and still complete the request

Rationale

What is tested? the emission fail-open path - the exchange event is LOST, the request is not.

How is success determined? no exception reaches the caller, no event is emitted, the emission counter reads 1 and the other stages stay 0.

Why does it matter? this is exactly the state the metric exists for - the missing line is the symptom, so only the counter makes the loss observable.

should count a pre-gate emission failure instead of letting it escape the destruction callback

Rationale

What is tested? the widened emission guard - the section BEFORE the level gate (here: the status read) is fallible too.

How is success determined? a response whose status getter throws at destruction time yields no exception, no event, and emission=1.

Why does it matter? before the fix this escaped into the container's listener invocation and the event was lost WITHOUT the emission counter seeing it - the counter's own blind spot.

should re-raise the interrupt flag and count the emission when the emission is interrupted

Rationale

What is tested? the InterruptedException branch of the emission guard - an async appender can block interruptibly, so this path is reachable in production.

How is success determined? the event is dropped, emission=1, and the INTERRUPT FLAG is set again on the thread so a shutdown signal still reaches its addressee.

Why does it matter? consuming an interrupt on a request-serving thread leaves whoever sent it waiting; the flag restoration is the whole point of the dedicated catch.

Pre-registration

should pre-register all three stages at zero and stay at zero for a healthy exchange

Rationale

What is tested? the counters exist from construction (a dashboard can alert on them before the first failure) and a normal exchange touches none of them.

How is success determined? emission, arrival and wiring all read exactly 0 after a clean exchange.

Why does it matter? a counter created lazily on first failure is invisible to a rate() alert until the very moment it should already be firing. Given/

Wiring stage

should count a failed async MDC registration as stage wiring and still serve and log

Rationale

What is tested? the fail-open guard around the per-request CallableProcessingInterceptor registration - WebAsyncUtils stores its manager as a request attribute, and a request that refuses exactly that attribute makes the registration throw.

How is success determined? the chain runs, nothing propagates, wiring reads 1, and the exchange event is still emitted at destruction (the registration is bookkeeping, not the emission).

Why does it matter? worker logs lose the identity in that case, the request must not lose anything (code analysis of 2026-09-05, finding 6).

should count broken post-chain wiring as stage wiring while the event itself still arrives

Rationale

What is tested? the wiring fail-open path in the filter's finally - here the async-marker registration fails, but the emission at request destruction is independent of it.

How is success determined? the wiring counter reads 1 AND the exchange event still exists - proving the stages are distinct: wiring loss degrades, emission loss silences.

Why does it matter? an operator reading the metric must know whether an increment means "a line is missing" (emission) or "a line may be incomplete" (wiring).

should degrade to a pass-through when the wiring itself fails and still serve the request

Rationale

What is tested? the fail-open contract for the WIRING - a throwing host-provided CorrelationIdGenerator must not fail the request.

How is success determined? the chain still runs, nothing propagates, the wiring counter reads 1, and no event is emitted (the exchange was never wired).

Why does it matter? before the fix, any pre-chain exception failed the request with a 500 - the exact outcome the documented fail-open contract rules out.

should keep logging the exchange when the correlation-source counter throws at wiring

Rationale

What is tested? the isolation of an OPERATIONAL counter from the exchange it observes - registration succeeds, the increment throws, everything else is healthy.

How is success determined? the event is emitted as usual; the counter failure is counted stage=wiring; nothing propagates.

Why does it matter? unguarded, the throw aborted the wiring and degraded the request to an unlogged pass-through - one broken metric suppressed viable exchange logging.

should keep serving and logging when the host registry already owns an endpoint meter of another type

Rationale

What is tested? fail-open meter registration (twin parity with the reactive module) - Micrometer rejects an id that exists with a different type.

How is success determined? the filter constructs against a registry that pre-registered the fail-open meter as a GAUGE and the body-size meter as a COUNTER; the exchange runs and its event is emitted; the host's meters are untouched and the conflicting ones stay private.

Why does it matter? unguarded, the construction throw aborts the application context and the lazy body-size throw suppresses the whole exchange event.

should not report an emitted event as an emission failure when the events counter throws

Rationale

What is tested? the post-log() increment of the events counter - the line is already on the logger when it throws.

How is success determined? the event is on the appender, stage=emission stays 0 (the event was NOT lost) and the counter failure lands on stage=wiring.

Why does it matter? the fail-open and events meters are the reconciliation signals for log-pipeline loss; a false "emission failed" for an emitted line makes them lie.

should still serve the request when the fail-open diagnostics themselves throw

Rationale

What is tested? the secondary guard around the catch handlers' diagnostics (twin parity with the reactive module) - the wiring fails AND the fail-open counter's increment throws.

How is success determined? the chain runs and nothing propagates out of the filter.

Why does it matter? a throw escaping a catch handler before the chain fails the request with a 500.

RequestLoggingFilterAsyncTest

11 tests.

Async dispatch participation

should carry the MDC into the async dispatch and record its failure on the existing exchange

Rationale

What is tested? the second filter pass for the container's ASYNC dispatch - Spring MVC renders the async result or rethrows the async failure in that dispatch; the filter used to skip it entirely.

How is success determined? the dispatch chain observes the endpoint_* MDC; an exception thrown there propagates unchanged; at destruction exactly ONE event exists, ERROR/failure with that exception as cause, endpoint_async=true, under the ORIGINAL correlation id (the exchange was reused, not re-wired); the test thread's MDC is clean afterwards.

Why does it matter? a failing Callable used to log WARN without a cause while the sync equivalent logged ERROR with it - alerting disagreed between sync and async endpoints.

should pass an async dispatch through untouched when no exchange is attached

Rationale

What is tested? an ASYNC dispatch of a request the filter never wired - excluded or failed open.

How is success determined? the chain runs, nothing is logged, and destruction finds nothing to emit.

Why does it matter? the per-dispatch path must not wire a second exchange for a request the initial dispatch deliberately skipped.

should rethrow the application exception although the async breadcrumb backend throws

Rationale

What is tested? the fail-open guard around the async dispatch's breadcrumb - a double fault: the async handler fails AND the module logger's backend throws (a throwing TurboFilter, consulted inside warn()).

How is success determined? the container receives the APPLICATION exception, not the backend's; the failure is recorded on the exchange (the event at destruction carries it); the breadcrumb loss is counted stage=wiring.

Why does it matter? before the guard the backend exception replaced the handler's on its way to the container's error handling - wrong diagnostics and a violated fail-open contract.

Async dispositions

should escalate to ERROR with the cause when async processing ends in onError

Rationale

What is tested? an exchange in async mode whose async phase fails through the container's AsyncListener before destruction.

How is success determined? one ERROR event with outcome failure and the async failure as its cause.

Why does it matter? an async failure never passes the filter's own catch block; the listener is the only place it can be observed.

should keep outcome timeout when the timeout event carries a throwable

Rationale

What is tested? the callback-vs-throwable classification - the servlet API permits onTimeout WITH a throwable, and inferring the outcome from throwable presence misfiled such a timeout as a generic failure.

How is success determined? WARN with endpoint_outcome=timeout - the CALLBACK classifies - while the supplied throwable still rides the event as its cause.

Why does it matter? timeout dashboards key on the outcome; a timeout that shape-shifts into "failure" whenever the container supplies a cause makes the timeout rate unusable.

should log outcome failure at ERROR when onError arrives without a throwable

Rationale

What is tested? the complement of the previous case - the servlet API explicitly permits an onError event with a NULL throwable, and an implementation keying on the throwable logged such an exchange as a clean success.

How is success determined? ERROR with endpoint_outcome=failure and no cause on the event.

Why does it matter? a failed async exchange logging as success is silent data corruption in every error-rate dashboard - the exact defect class the outcome field exists to prevent.

should log outcome timeout at WARN when the container times the exchange out

Rationale

What is tested? the onTimeout path, which - unlike onError - usually carries NO throwable.

How is success determined? one WARN line with endpoint_outcome=timeout, no cause.

Why does it matter? without the explicit timedOut flag a container timeout would be indistinguishable from a clean completion and log as success.

Lifecycle

should complete via the onComplete backstop when no destruction follows a raw completion

Rationale

What is tested? the onComplete backstop - a raw async cycle ends via complete() WITHOUT a further dispatch, so a per-dispatch container never fires another destruction; the marker completes the exchange exactly when the skipped-destruction flag is armed.

How is success determined? the early destruction emits nothing; the container's onComplete then yields exactly one line - and a late duplicate destruction stays a no-op.

Why does it matter? without the backstop, raw-async exchanges on Jetty lost their event entirely (and leaked the open-exchanges gauge) after the early-destruction skip.

should defer the exchange line until request destruction and measure the full duration

Rationale

What is tested? the dispatcher-return-before-response problem - for an async exchange, doFilter returns while the controller is still working, so neither status nor duration exist yet. Emission happens only at request destruction, AFTER async completion.

How is success determined? NOTHING is logged when doFilter returns, and still nothing at onComplete (the listener only marks); after requestDestroyed exactly one line exists whose duration covers the async phase (30 ms chain + 70 ms async = 100 ms), marked async=true with the final status.

Why does it matter? logging synchronously would report duration~=0 and status 200-by-default for every coroutine controller - systematically wrong data for exactly the endpoints where latency matters.

should log exactly once when destruction fires twice after a burst of terminal events

Rationale

What is tested? the exactly-once guard behind the emission.

How is success determined? a burst of async events followed by TWO requestDestroyed calls still yields ONE line.

Why does it matter? duplicated exchange lines double request counts in every log-derived metric; the guard must hold against container quirks on either callback.

should skip a destruction fired while async is still running and complete at the final one

Rationale

What is tested? the per-dispatch destruction model (Jetty fires requestDestroyed at the end of EVERY dispatch) - a destruction observed before the cycle's onComplete must not emit and must not consume the exchange; after onComplete the completion happens exactly once, whichever callback gets there first.

How is success determined? nothing is logged at the early destruction; exactly one line after the final one, with the final status.

Why does it matter? on Jetty the early destruction used to emit a pre-completion 200 for exchanges whose client later received a 500, and to strip the exchange the async-dispatch pass depends on (Jetty capture-boundary integration test, 2026-08-30).

RequestLoggingFilterBodyAndHeaderTest

31 tests.

Header capture

should log every value of a repeated header, comma-joined

Rationale

What is tested? multi-value header resolution - a single-value getHeader would silently truncate repeated headers.

How is success determined? both Accept values appear in the rendered field, comma-joined.

Why does it matter? repeated headers (Accept, Set-Cookie) are exactly the ones whose LOSS is invisible - the field still shows a plausible single value.

should log the configured request and response headers

Rationale

What is tested? explicit header selection per direction on a request with an unselected Authorization header.

How is success determined? exactly the selected headers appear, one field per direction; Authorization appears nowhere.

Why does it matter? selection is allowlisting; an unselected secret reaching the line would be a leak the operator never configured.

should omit a configured header that the exchange does not carry

Rationale

What is tested? a selected header the request and response do not carry.

How is success determined? both header fields are absent, not logged as null or empty.

Why does it matter? an absent field keeps the index sparse and honest; an empty string would look like a header with no value.

Header masking and selection

should include every header via the wildcard except the excluded ones

Rationale

What is tested? the wildcard include with one exclusion and masking switched off explicitly.

How is success determined? Accept and User-Agent are logged verbatim, the Cookie is absent by name and value, matched case-insensitively.

Why does it matter? the wildcard is the debugging configuration; an exclusion that failed case-insensitively would leak the one header it was meant to keep out.

should mask every selected header by default so a wildcard include never leaks plaintext

Rationale

What is tested? ADR-0005 at the filter - the documented debugging move includes: ["*"] with nothing said about masking.

How is success determined? every logged request header is a fingerprint; the secret appears nowhere.

Why does it matter? with masking as a second, empty list the same configuration logged everything in plaintext - the unsafe combination was the convenient one. Given

should render equal masked values as equal fingerprints and different values as different ones

Rationale

What is tested? the stability half of the masking contract, across two exchanges.

How is success determined? same secret -> same rendered headers field; different secret -> different.

Why does it matter? correlation by fingerprint is the entire reason the value is hashed instead of simply dropped. Given/

should render equal-length values that collided under String hashCode as distinct fingerprints

Rationale

What is tested? the collision model of the fingerprint after its widening (applied in lockstep with the reactive twin) - length plus 32-bit String.hashCode was NOT injective; length plus a 64-bit SHA-256 prefix separates the well-known colliding pair.

How is success determined? "Aa" and "BB" (equal hashCode, both length 2) mask to DIFFERENT strings of the new 16-hex format.

Why does it matter? a collision made two distinct secrets one logged identity - a wrong correlation conclusion that the widened fingerprint makes negligible.

should render masked values through a host-provided masker

Rationale

What is tested? the masker is an injected collaborator - a filter built with a host bean masks request AND response headers with it.

How is success determined? both selected, masked headers carry the host masker's output, never the plaintext and never the built-in fingerprint.

Why does it matter? a compliance regime forbidding unkeyed hashes must be satisfiable without forking the module.

should replace a masked header value by a stable fingerprint that never shows the value

Rationale

What is tested? the masking contract - non-reversible but STABLE, and case-insensitive on the header name (configured lowercase, sent canonical).

How is success determined? the raw value appears nowhere in the event; the rendered fingerprint has the length:hex shape; the unmasked header stays verbatim.

Why does it matter? masked headers exist to keep a secret out of the log WITHOUT losing the ability to correlate requests that carried the same token.

Outcome-gated bodies

should log both bodies of a 4xx response although its outcome stays success

Rationale

What is tested? the gate is wider than the outcome vocabulary by one status class - a 4xx keeps its success outcome (the application answered; the client's request was wrong) but is exactly the case a body explains.

How is success determined? outcome success, and BOTH bodies on the line.

Why does it matter? a validation error\'s response body is the most wanted body of all; hiding it behind the outcome vocabulary would make on-failure useless for client errors. Given/

should log both bodies of a 5xx response in on-failure mode

Rationale

What is tested? on-failure body logging for a failure outcome WITHOUT an exception - a 502 the handler answered.

How is success determined? outcome failure, request and response body on the line.

Why does it matter? a 5xx is a failure to the operator whether or not an exception was involved; the body gate must follow the outcome, not the exception. Given/

should log the teed request body of an exchange whose handler threw in on-failure mode

Rationale

What is tested? on-failure body logging when the handler read the body and then threw.

How is success determined? the exception propagates; the event carries outcome failure and the request body, and no response body.

Why does it matter? the request body flowed before the outcome was known; on-failure must keep it for exactly this line and drop it for a clean one.

should still measure the size of a body it withholds

Rationale

What is tested? on-failure plus request-body measuring on a successful exchange.

How is success determined? the size sample is recorded, the body field is absent.

Why does it matter? the meter and the field are independent opt-ins; a body the mode withholds from the log is still bytes that flowed.

should withhold both bodies from a successful exchange in on-failure mode

Rationale

What is tested? the volume switch - on-failure tees the request body (the outcome is unknown while it is read) and discards both captures at emission when the outcome is success.

How is success determined? the client receives the response body; the line carries neither body.

Why does it matter? this is the mode that keeps body logging affordable outside a debug session. Given/When

Request body capture

should enforce the stream-reader either-or contract like an unwrapped request

Rationale

What is tested? the servlet exclusivity contract the wrapper must reproduce itself - the tee serves both public APIs from ONE delegate stream, so the delegate can no longer see which API the application chose.

How is success determined? reader-after-stream and stream-after-reader both throw IllegalStateException; the same accessor repeated stays legal (cached object).

Why does it matter? erroneous downstream code consuming both APIs would fail on an unwrapped request - enabling body logging must not silently legalize it. Given/When/

should hand the application container-default decoded text when no encoding is declared

Rationale

What is tested? the reader's charset transparency - with no declared request encoding, the wrapper's reader must decode with the servlet default ISO-8859-1 like an unwrapped request, NOT with the log-side UTF-8 fallback.

How is success determined? the application reads the exact ISO-8859-1 text; the fixture's non-ASCII byte is one whose UTF-8 and ISO-8859-1 decodings differ, so the old behavior would have delivered a replacement character instead.

Why does it matter? a logging feature that changes the characters the application receives can corrupt validation, persistence and responses - the one thing a passive tee must never do.

should log the body with the encoding the chain set before reading, not the one at filter entry

Rationale

What is tested? the late binding of the LOG charset - the servlet contract allows setCharacterEncoding until the body is consumed, so downstream code may change the encoding after the filter constructed its wrapper.

How is success determined? with ISO-8859-1 declared at entry and the chain switching to UTF-8 before reading a UTF-8 body, BOTH the application-visible text and the logged body read "h\u00e4llo"; a charset frozen at entry would have logged the two-byte sequence as "h\u00c3\u00a4llo".

Why does it matter? the logged body is forensic evidence; decoding it with an encoding the application never used makes it wrong on a fully valid servlet flow.

should log the request body the application actually read

Rationale

What is tested? the request-body tee on a chain that consumes the body through the input stream.

How is success determined? endpoint_request_body is exactly what was read.

Why does it matter? the tee mirrors consumption; a wrapper the chain could bypass would log an empty body without another symptom.

should log the request body when the application reads via the reader

Rationale

What is tested? the character API - a chain that consumes the body through getReader.

How is success determined? the logged body is what the reader delivered.

Why does it matter? the wrapper must route the reader over its own tee stream, or the capture would be silently bypassed by every text-reading handler.

should omit the request body key when the application read nothing

Rationale

What is tested? the tee's truthfulness - a body that was never consumed by the application.

How is success determined? no requestBody key at all, rather than an empty or fabricated value.

Why does it matter? a pre-buffering design would log bytes the application never touched; the tee design makes "logged" mean "actually flowed".

should report the delegate's available bytes through the tee stream

Rationale

What is tested? available() of the tee stream - inherited from InputStream it answers a constant 0, delegated it answers what the container's stream answers.

How is success determined? before any read the tee reports the full body length, after reading two bytes the remainder - exactly like the unwrapped stream.

Why does it matter? a parser probing available() must behave identically with and without body capture; a passive tee that changes the answer changes the application (code analysis of 2026-09-05, finding 5).

should truncate the logged request body at the configured limit and say so

Rationale

What is tested? a 16-byte body against an 8-byte cap.

How is success determined? the captured prefix plus the explicit truncation note with the total.

Why does it matter? the cap bounds memory, not the exchange; the note is what tells an operator the body was longer than what they see.

Response body capture

should encode a surrogate pair split across writer calls exactly like the client bytes

Rationale

What is tested? the writer tee's byte fidelity - the capture runs through ONE stateful encoder with the writer's lifecycle, so a surrogate pair whose halves arrive in separate write calls is encoded as one character; the old chunk-local String.toByteArray produced replacement bytes for each half.

How is success determined? the logged body and the client body are the identical two-char (4-byte UTF-8) sequence although the halves crossed a write boundary.

Why does it matter? replacement bytes in the capture make the logged text differ from the client response and the body-size metric count the wrong bytes - silently, only for non-BMP characters.

should log the response body written through the output stream and leave the client response intact

Rationale

What is tested? the response tee on a chain writing through the stream API.

How is success determined? the body is logged AND fully delivered to the client.

Why does it matter? a tee, not a cache: a wrapper that buffered or altered the bytes would change the response it observes.

should log the response body written through the writer and leave the client response intact

Rationale

What is tested? the response tee on a chain writing through the character API.

How is success determined? the body is logged AND fully delivered to the client.

Why does it matter? the writer must sit on the same tee stream as the output stream, or text-writing handlers would produce unlogged bodies.

should surface the delegate writer's suppressed error state through checkError

Rationale

What is tested? the PrintWriter error contract - the container's writer IS a PrintWriter that swallows IOExceptions into an internal flag; the wrapper's outer PrintWriter used to consult only its own healthy tee and answered false after the real writer had failed.

How is success determined? after a write against a delegate whose underlying writer throws, checkError() on the wrapper's writer returns true.

Why does it matter? checkError() is the ONLY failure signal the servlet writer API offers; application code polling it lost the client-disconnect signal the moment body capture was enabled.

Response reset handling

should allow switching from writer to stream and back across a full reset

Rationale

What is tested? the mode switch Servlet 6.1 explicitly permits ("getWriter, reset and then getOutputStream") - the wrapper must not bypass the delegate's either-or check through a cached opposite accessor, and the tee must follow the mode actually in use.

How is success determined? no IllegalStateException, and both the client body and the logged body show exactly the bytes written through the post-reset accessor.

Why does it matter? valid servlet code must behave identically with and without the logging filter installed.

should discard the capture when a redirect clears the buffered response

Rationale

What is tested? the redirect half of the buffer-replacement handling - sendRedirect also clears the buffer per the servlet spec, again without traversing the reset overrides.

How is success determined? after write-then-sendRedirect, no response-body field is logged.

Why does it matter? same stale-body defect as sendError, on the redirect path.

should discard the capture when sendError replaces the buffered response

Rationale

What is tested? the buffer-replacing operations beyond reset()/resetBuffer() - sendError clears the delegate's buffer per the servlet spec WITHOUT calling the reset overrides, so the capture must follow the buffer through the wrapper's own sendError override.

How is success determined? after write-then-sendError, the event carries NO response-body field - the pre-error bytes never reached the client (the rendered error page bypasses the tee through the container's ERROR dispatch, the documented boundary).

Why does it matter? a stale "discarded" body on exactly the failure responses operators investigate is worse than an absent one - it asserts content the client never received.

should hand out a fresh writer over the delegate after a full reset

Rationale

What is tested? the accessor-state half of reset() - Servlet 6.1 clears the writer/stream selection on reset(), so the accessor returned afterwards must be a NEW tee over the delegate's new writer, not the cached one over a stale delegate object.

How is success determined? the post-reset writer is a different instance, both the client body and the logged body show only the post-reset content, and the capture encodes with the charset set AFTER the reset (reset also clears the encoding the first writer pinned).

Why does it matter? exception handlers reset and rewrite responses; a stale cached writer would encode with the old charset and, on a real container, write through an object whose behavior after reset is undefined - client-visible corruption only when logging is on.

should log only the content that survived a resetBuffer

Rationale

What is tested? the tee's alignment with container buffer semantics - a reset of an uncommitted response discards everything written so far.

How is success determined? after write-reset-write, the logged body and the client body BOTH show only the post-reset content.

Why does it matter? error handlers rewrite partially written responses; a tee that kept the discarded bytes would log content the client never received - on exactly the exchanges one investigates most.

RequestLoggingFilterJettyIntegrationTest

8 tests.

should discard buffered output replaced by sendError and log no stale body on Jetty

Rationale

What is tested? the sendError boundary on Jetty - the controller writes into the buffer and then replaces the response via sendError(503); the buffer discard and the wrapper's capture discard must stay in lockstep, and Jetty's error rendering must bypass the tee.

How is success determined? the client sees the 503 error rendering (never the discarded bytes), and the WARN/failure event carries NO endpoint_response_body.

Why does it matter? sendError's buffer-clearing is exactly the spec corner where containers could diverge - a divergence here logs bodies the client never received on the failure responses operators investigate. Given/

should echo the correlation id on Jetty including the error-dispatched 500

Rationale

What is tested? the identity contract on the second container - traceless echo at filter entry, surviving Jetty's error dispatch.

How is success determined? the pinned generator's id is echoed on a happy response and on the 500.

Why does it matter? failures are exactly the responses a support case needs to correlate; the echo's survival across the error dispatch is container behavior, not module code. Given/

should log a DeferredResult error result at ERROR under Jetty's per-dispatch destruction

Rationale

What is tested? a DeferredResult completed with an error result on real Jetty, whose request destruction fires per dispatch.

How is success determined? the client sees 500; one ERROR event with outcome failure, endpoint_async true and the deferred failure in the cause chain.

Why does it matter? Jetty's destruction model differs from Tomcat's; the exactly-once guard and the async classification must hold under it too. Given/

should log a completed MVC async exchange with its body under Jetty's per-dispatch destruction

Rationale

What is tested? the emission point under Jetty's destruction model - Jetty fires requestDestroyed at the end of EVERY dispatch, so the initial dispatch of an async exchange destroys early; the exchange must survive that, the async-dispatch pass must still see it, and the event must carry the COMPLETED state.

How is success determined? one INFO event with endpoint_async=true, the final status and the response body the async worker wrote - and the render-side MDC observed by the advice.

Why does it matter? before the per-dispatch handling, Jetty emitted a bodyless pre-completion event and stripped the exchange from the async dispatch (found by this suite, 2026-08-30). Given/

should log a failing Callable at ERROR with the final 500 under Jetty's per-dispatch destruction

Rationale

What is tested? the async error path on Jetty - the failure is rethrown in the ASYNC dispatch (which needs the exchange to still be attached there) and the emission carries the FINAL rendered status, not the pre-completion 200 the early destruction used to log.

How is success determined? client 500; one ERROR event, outcome failure, endpoint_async=true, status 500, the cause chain naming the Callable's exception.

Why does it matter? an async crash that logs "200 success" is wrong data on exactly the exchanges an operator investigates - the defect this suite originally exposed. Given/

should log the final 500 of an unhandled exception without capturing Jetty's error body

Rationale

What is tested? the error-dispatch boundary on Jetty - an unhandled controller exception is rethrown unchanged, Jetty renders the 500 through the ORIGINAL response (the filter skips the ERROR dispatch), and the emission at request destruction still reports the FINAL status.

How is success determined? client 500 with a rendered body; one ERROR event with outcome failure, the original cause, status 500 - and NO endpoint_response_body.

Why does it matter? the "final status, no error body" pair is the exact combination that depends on the container's dispatch ordering; both halves must hold per container. Given/

should pin that zero-argument servlet async bypasses the body tee on Jetty too

Rationale

What is tested? the raw-async boundary on Jetty - the Servlet spec initializes a zero-argument startAsync() context with the ORIGINAL request/response, so the raw worker writes beside the tee.

How is success determined? the raw-async response reaches the client, the exchange event exists and carries NO endpoint_response_body although log-response-body is enabled class-wide.

Why does it matter? a container that handed the wrappers to the zero-argument context would silently change what gets captured - the pin keeps the boundary identical across containers. Given/

should tee both bodies through Jetty's real streams and emit at request destruction

Rationale

What is tested? the baseline on the second container - Jetty's real request/response streams flow through the tee wrappers, and Jetty fires requestDestroyed, the emission point.

How is success determined? the echoed round trip succeeds and exactly one INFO event carries both bodies exactly as they flowed.

Why does it matter? every boundary assertion below is only meaningful if the capture demonstrably WORKS on this container in the regular case. Given/

RequestLoggingFilterJettyTracingIntegrationTest

2 tests.

should emit the parsed trace pair although the live bridge MDC is open at Jetty's in-dispatch emission

Rationale

What is tested? the emission scope's OWNERSHIP of the trace keys against an ACTIVELY maintained bridge MDC - under Jetty the destruction that emits runs inside the final dispatch, so the observation filter's scope is still open and the bridge's own traceId/spanId sit live in the MDC around the emission.

How is success determined? the event carries exactly the sent traceId and parentSpanId, NO spanId key, the request id IS the trace id, and the traced exchange gets no X-Correlation-Id echo although the caller supplied one.

Why does it matter? a regression in the suppression would surface as a foreign local spanId on every event ONLY on containers with in-dispatch emission - the Tomcat twin cannot catch it. Given/

should keep the parsed trace context across Jetty's per-dispatch async choreography

Rationale

What is tested? the trace context surviving the async emission path on Jetty - the initial dispatch's destruction is skipped, the async dispatch renders, and the completed cycle's destruction (or backstop) emits; the traceparent-derived pair lives on the Exchange, so no step of that choreography may lose it.

How is success determined? the async exchange's event carries the sent trace id as trace field AND as request id, with no spanId leaked from the bridge's worker/dispatch MDC.

Why does it matter? async is exactly where the emission thread and its ambient MDC differ most between the containers; this is the async half of the suppression pin above. Given/

RequestLoggingFilterTest

24 tests.

Correlation id and MDC

should adopt the correlation id from the request header and echo it on the response

Rationale

What is tested? a request already carrying a correlation id.

How is success determined? the caller's id is echoed on the response, rides the event's MDC and appears inline in the message.

Why does it matter? ADR-0002 on the inbound side: a traceless caller's correlation is honoured, and the echo lets the caller quote it.

should expose the exchange identity in the MDC while the chain runs

Rationale

What is tested? the chain-scoped MDC - a chain that records the MDC it observes.

How is success determined? downstream code saw request id, method and route.

Why does it matter? every log line the application writes while serving the request inherits the identity through the MDC; that join is the point of the scope.

should generate a correlation id when the request carries none

Rationale

What is tested? a request without the correlation header.

How is success determined? the pinned generator's id is echoed on the response and rides the event's MDC.

Why does it matter? every exchange needs an identity; a missing header must not leave the line and the response without one.

should restore the previous MDC values after the exchange

Rationale

What is tested? MDC restoration on a POOLED container thread, including the case where an OUTER component owns the same keys.

How is success determined? a pre-existing value is back after the filter ran, and a key that did not exist before is absent again (not left as an empty leftover).

Why does it matter? leaked MDC entries attach a finished request's identity to the NEXT request handled by the same pooled thread - the classic source of wrong correlation ids in logs.

should treat an over-long or non-token correlation header value as absent and generate instead

Rationale

What is tested? the acceptance rule for caller-supplied ids (CorrelationHeaderValue) at the filter - a value beyond 128 characters and a value with an inner space.

How is success determined? both exchanges carry the generated id in event and echo; the caller's value appears nowhere on the line.

Why does it matter? an accepted value is written into every log line and MDC of the exchange; without the bound the peer dictates log volume and id shape (code analysis of 2026-09-05, finding 11).

Level escalation

should compare the slow threshold at full precision instead of truncated milliseconds

Rationale

What is tested? twin parity with the reactive module's threshold fix - a 1.5 ms threshold used to truncate to 1 ms and flag a 1 ms exchange.

How is success determined? 1.0 ms is NOT slow, 1.5 ms IS slow, under a 1.5 ms threshold.

Why does it matter? both twins must classify identically, or the same request would be WARN on one stack and INFO on the other.

should escalate to WARN and flag the exchange when the slow threshold is reached

Rationale

What is tested? exactly the configured 200 ms threshold consumed by a clean exchange.

How is success determined? WARN, endpoint_slow true, duration 200 ms, outcome still success.

Why does it matter? slowness raises severity and must never turn a completed exchange into a failure; the boundary is inclusive.

should escalate to WARN for a server error status

Rationale

What is tested? the classification of a chain that answers 503.

How is success determined? WARN with status 503 and outcome failure.

Why does it matter? severity and semantic are decoupled: the handler answered, so WARN, while the outcome tag still counts the failure.

should log a chain exception at ERROR and rethrow the same instance

Rationale

What is tested? the failure path must ADD visibility without CHANGING error semantics - the container's error handling classifies on the exception instance.

How is success determined? the thrown object is the identical instance (not a copy or wrapper), and exactly one WARN line carries it as cause.

Why does it matter? a filter that wraps or swallows exceptions silently breaks error pages, @ControllerAdvice handlers, and retry semantics of everything downstream.

should log an immediate WARN breadcrumb on the module logger when the chain throws

Rationale

What is tested? the breadcrumb logged in the filter's finally, at the failure site.

How is success determined? one WARN line on the FILTER's own logger (not the exchange logger) naming method, path, exception and correlation id - before the full event, which only arrives at request destruction.

Why does it matter? the full ERROR event is deferred until after the container's error dispatch; without the breadcrumb the log stream would show the container's stack trace with no immediate hint which exchange it belongs to.

Path exclusions

should be active only for endpoints matching an include pattern

Rationale

What is tested? the include side of the activation rule - patterns determine for which endpoints the filter runs AT ALL.

How is success determined? a matching path produces an event with correlation echo; a non-matching path produces neither (the filter never ran).

Why does it matter? hosts scope exchange logging to their API surface; a filter that still ran on static resources would log and echo where it should be invisible.

should exclude a percent-encoded variant of an excluded prefix

Rationale

What is tested? the exclude rule sees the request target the way the router does - a byte-wise startsWith on the raw URI let /%61ctuator/health through although the container serves it as /actuator/health.

How is success determined? the encoded variant produces no event, exactly like the plain one.

Why does it matter? operators exclude paths to keep probes out of the log - or to keep sensitive routes out of it; an encoding trick must not undo either.

should include a percent-encoded variant of an included pattern and log the raw path

Rationale

What is tested? the include side decodes per segment like Spring MVC's handler mapping, so activation and routing agree on /%61pi/things; the logged path stays raw as sent.

How is success determined? one event, endpoint_url_path carries the encoded form.

Why does it matter? a request the router serves under /api/** must not evade include-scoped logging, and the log must not decode control sequences (twin parity with the reactive module's raw-path contract).

should let an exclude win inside an included pattern

Rationale

What is tested? an exclude prefix nested inside an include pattern.

How is success determined? the excluded-inside-included exchange produces no event.

Why does it matter? an exclude always wins - the rule the header sections follow too; a different precedence would surprise an operator configuring both.

should match activation against the path within the application under a context path

Rationale

What is tested? finding 3 of the repo-wide code analysis of 2026-08-30 - activation must match what Spring MVC's handler mapping matches, the path WITHIN the application, not the full request URI that includes server.servlet.context-path.

How is success determined? under context path /app, /app/api/things is logged by include /api/** and /app/actuator/health is excluded by /actuator/health; matching against the full URI would silently invert both.

Why does it matter? on a non-root deployment, include patterns modeled after MVC routes otherwise match nothing - total, silent loss of exchange logging exactly where the operator configured it.

should not log an excluded path at all

Rationale

What is tested? the exclude prefix through the full OncePerRequestFilter entry point and the destruction callback.

How is success determined? no event; destruction finds no exchange.

Why does it matter? health probes must produce no line and no wiring; a suppressed-but- wired exchange would still cost a gauge movement per probe.

should reject an invalid include pattern at construction time

Rationale

What is tested? a syntactically broken PathPattern in the include list.

How is success determined? the constructor throws the parser's PatternParseException whose detail names the malformed pattern.

Why does it matter? a configuration error must fail the context start with a diagnostic, not fail per request or match nothing silently. Given/

should still log a path that merely resembles an excluded prefix

Rationale

What is tested? a path sharing characters with an excluded prefix without matching it.

How is success determined? the exchange is logged.

Why does it matter? prefix matching must be a prefix match, not a substring or fuzzy one; an over-eager exclude would hide real endpoints.

The exchange line

should log method path status and duration of a completed exchange

Rationale

What is tested? the completion event of a plain synchronous exchange - message, level and the endpoint_* fields including the measured duration.

How is success determined? one INFO event with the literal message, outcome success, method, path, status 200, duration 42 ms, async false and no slow flag.

Why does it matter? this is the line every other test refines; the field family and the message format are the contract dashboards and appenders key on.

should log the handler pattern as the url template field when the dispatch recorded one

Rationale

What is tested? the BEST_MATCHING_PATTERN attribute Spring MVC records during the chain, read at emission.

How is success determined? the expanded path and the pattern with its placeholder in their two fields.

Why does it matter? the template is the low-cardinality aggregation half of the path pair; without it every id would be its own bucket.

should log the query string as its own field beside the query-free path

Rationale

What is tested? a request with a query string.

How is success determined? endpoint_url_path carries the path without the query, endpoint_url_query the query.

Why does it matter? grouping by path must not be defeated by varying query strings; the query rides its own filter-only field.

should omit the query string when disabled

Rationale

What is tested? includeQueryString=false on a request with a query string.

How is success determined? the query appears neither in the message nor as a field.

Why does it matter? query strings may carry personal data; the switch must remove them from every rendering, not only the field.

The optional start line

should announce the exchange before the chain runs when enabled

Rationale

What is tested? the arrival line fires BEFORE the chain, and the completion event still follows as the only outcome-carrying line.

How is success determined? at chain time exactly one line exists and it is the start line; after completion there are two, and only the second carries endpoint_outcome.

Why does it matter? the option exists for exchanges that hang or never complete - a start line that fired only afterwards would be worthless for exactly that case; and a start line carrying an outcome would double every count keyed on the outcome field.

should not log a start line by default

Rationale

What is tested? the default configuration's handling of one exchange.

How is success determined? exactly one event, and it is not the start line.

Why does it matter? the arrival line is opt-in; a default that emitted two lines would double every host's log volume on upgrade. Given/

RequestLoggingFilterTomcatIntegrationTest

13 tests.

should capture both bodies of a real round trip through the tee wrappers

Rationale

What is tested? both tees on real Tomcat streams - a POST whose controller reads and echoes the body.

How is success determined? the client sees the echo; the event carries the request body and the echoed response body.

Why does it matter? Tomcat's streams differ from the mocks (buffering, flush timing); only the real container proves the wrappers observe without disturbing. Given/

should carry the endpoint MDC into the real async dispatch that renders the result

Rationale

What is tested? the ASYNC-dispatch pass against real Tomcat and real MVC - the Callable's result is rendered in the container's ASYNC dispatch, where a ResponseBodyAdvice observes the MDC.

How is success determined? the advice appends the correlation id it saw during rendering; the body therefore carries it twice - from the worker AND from the rendering dispatch.

Why does it matter? converters, advice and interceptors log in that phase; before the fix their lines lost the identity. Given/

should discard buffered output replaced by sendError and log no stale body

Rationale

What is tested? the sendError half of the buffer-replacement handling against the real container - the controller writes into the buffer and then replaces the response via sendError; the wrapper's sendError override discards the capture with the buffer, and the rendered error page is written through the ERROR dispatch outside the tee (the documented boundary).

How is success determined? the client sees the 503 error rendering (not the discarded bytes), and the WARN/failure event carries NO endpoint_response_body - neither the stale pre-error bytes nor a fabricated error body.

Why does it matter? stale bodies on exactly the failure responses operators investigate assert content the client never received. Given/

should echo the correlation id even on the error-dispatched 500 response

Rationale

What is tested? the client-visible half of the error path - whether the correlation echo set at filter entry survives the container's error dispatch.

How is success determined? the 500 response carries the X-Correlation-Id header.

Why does it matter? the reference configuration promises the id is ALWAYS echoed; failures are exactly the responses a support case needs to correlate. Given/

should generate a correlation id from the pinned generator when the caller sends none

Rationale

What is tested? the generated-id path on real Tomcat.

How is success determined? the pinned id is echoed on the wire and carried in the event's MDC.

Why does it matter? the echo header must survive the real container's response handling, or callers could never quote the id. Given/

should log a DeferredResult error result at ERROR with its cause via the async dispatch

Rationale

What is tested? a DeferredResult completed with an error result on real Tomcat - the async dispatch path.

How is success determined? the client sees 500; one ERROR event with outcome failure, endpoint_async true and the deferred failure in the cause chain.

Why does it matter? a DeferredResult error reaches the filter through the ASYNC dispatch, not the initial one; the classification must hold across that boundary. Given/

should log a failing Callable at ERROR with its cause via the async dispatch

Rationale

What is tested? the async failure path end to end - the Callable throws on the worker, MVC rethrows in the ASYNC dispatch, the filter's second pass records it, Tomcat renders 500.

How is success determined? client 500; one ERROR event, outcome failure, endpoint_async=true, the cause chain naming the Callable's exception.

Why does it matter? parity with the sync /it/boom case - the same crash must classify the same. Given/When

should log a real async exchange after completion with the async flag set

Rationale

What is tested? the genuine servlet async lifecycle - MVC's Callable support calls startAsync, the response is written on a worker thread, the CONTAINER fires onComplete - AND the worker-thread MDC: the Callable itself reads its own MDC and echoes the correlation id it saw there, so the response body is the proof that the identity reached the ASYNC WORKER, not merely the final emitter overlay.

How is success determined? the body carries the correlation id the worker observed in ITS MDC; one INFO event, endpoint_async=true, correct status, correlation id in the event's MDC.

Why does it matter? the async path is where naive synchronous logging silently reports wrong data, and worker logs without the identity were exactly the earlier defect - the mock test drives the listener by hand, only Tomcat proves the real thread hand-off. Given/

should log an unhandled controller exception at ERROR with outcome failure and still answer 500

Rationale

What is tested? the real error path - an unhandled controller exception propagates through DispatcherServlet and the filter chain, the filter records and rethrows it, Tomcat's error dispatch renders the 500.

How is success determined? the client sees 500, exactly one ERROR event with outcome failure whose cause chain names the original exception.

Why does it matter? the rethrow-unchanged contract is what keeps container error handling working; a filter that swallowed the exception would turn every crash into a half-written 200. Given/

should log one complete event for a real synchronous exchange including template headers and bodies

Rationale

What is tested? the full happy path through real Tomcat - filter registration by the auto-configuration, MVC dispatch, the tee wrappers on Tomcat's real streams, the recorded handler pattern, header selection, and the correlation echo.

How is success determined? the response is correct AND exactly one INFO event carries every endpoint_* field with the values the exchange really had.

Why does it matter? the servlet-mock tests prove the filter logic; only a real container proves the wiring - stream wrapping, attribute names, filter ordering - actually holds. Given/

should not log an excluded path while still logging the next regular exchange

Rationale

What is tested? the exclude prefix on real Tomcat, followed by a regular request.

How is success determined? both served with 200; exactly one event, for the regular path.

Why does it matter? an exclusion must not leave request state behind that suppresses or duplicates the next exchange on the same container. Given/

should pin that a form body the container parses bypasses the request tee

Rationale

What is tested? the documented capture BOUNDARY for framework-parsed bodies - a form POST read through @RequestParam makes the container parse the ORIGINAL request's stream; the wrapper's getInputStream()/getReader() are never selected.

How is success determined? the controller sees the field, the exchange event exists, and it carries NO endpoint_request_body although log-request-body is enabled class-wide (the response body, written through the tee, is present).

Why does it matter? form and multipart endpoints sit at unread on the read counter by construction; the pin keeps that boundary a conscious, documented contract (code analysis of 2026-09-05, finding 2). Given/

should pin that zero-argument servlet async bypasses the body tee by contract

Rationale

What is tested? the documented async capture BOUNDARY - Jakarta Servlet specifies that zero-argument startAsync() initializes its AsyncContext with the ORIGINAL request/response, so a raw async worker writes beside the tee; only the wrapper-preserving two-argument path (which Spring MVC uses) captures.

How is success determined? the raw-async response reaches the client, the exchange event exists, and it carries NO endpoint_response_body although log-response-body is enabled class-wide.

Why does it matter? this pin keeps the boundary a conscious contract - whoever makes wrapper retention an owned invariant across the zero-argument API must flip it deliberately. Given/

RequestLoggingFilterTomcatTracingIntegrationTest

2 tests.

should join the exchange event with the caller's traceparent beside a live bridge

Rationale

What is tested? the ADR-0002 identity and trace decision under a real bridge - the incoming traceparent is parsed by the module, its trace id becomes the request id, the caller's span rides parentSpanId, the bridge's local spanId is suppressed, and no correlation echo is written.

How is success determined? the event carries exactly the sent trace id (MDC and inline), parentSpanId equals the sent parent-id, no spanId MDC entry, and the response has no X-Correlation-Id.

Why does it matter? the live bridge writes its own traceId/spanId into the MDC around the chain - this is the assertion that the parsed header context wins over that ambient state and that a traced exchange passes through observationally untouched. Given/

should keep the trace context across a real async exchange

Rationale

What is tested? the entry-time parse surviving the async lifecycle - the emission runs at request destruction, on a container thread that never saw this exchange's request headers.

How is success determined? the async exchange's event carries exactly the sent trace id as request id and trace field.

Why does it matter? async is exactly where per-request state gets lost; the parse carried in the Exchange is what bridges it. Given/

RequestLoggingFilterTraceContextTest

8 tests.

should carry the traceparent context into the event although destruction runs on its own thread

Rationale

What is tested? the trace context is parsed from the traceparent header at filter entry and restored around the emission - which runs at request destruction, on a callback thread that carries no MDC of its own.

How is success determined? the emitted event carries traceId/parentSpanId as MDC fields and inline in the message; the parent-id is published under parentSpanId, never as the local spanId.

Why does it matter? without the header parse, a traced host loses the log-to-trace join for exactly the events that describe the exchange; publishing the caller's span as spanId would masquerade it as the local span (ADR-0002, twin parity).

should decorate the arrival line with the parsed trace pair and suppress a bridge spanId

Rationale

What is tested? arrival-line twin parity (finding 4 of the repo-wide code analysis of 2026-08-30) - the optional start line carries the SAME traceparent-derived traceId/parentSpanId as the completion event, and an ambient bridge spanId on the container thread does not ride along.

How is success determined? with log-request-start on, a traced request and a bridge-style spanId in the ambient MDC, the arrival event's MDC carries the parsed pair, no spanId - and the ambient value is back after the filter pass.

Why does it matter? before this fix the arrival line logged whatever trace keys the bridge had put into the MDC (local spanId included), diverging from the reactive twin's arrival line and from the module's own completion-event contract.

should emit without trace decoration when no traceparent is present

Rationale

What is tested? a request without a traceparent header run to destruction.

How is success determined? the event has neither trace MDC entries nor a trace suffix in the message.

Why does it matter? the traceless line is the common case; decoration with empty or placeholder ids would corrupt the log-to-trace join.

should fall back to the correlation contract when the traceparent is not conformant

Rationale

What is tested? an invalid traceparent counts as ABSENT (ADR-0002) - the traceless contract applies in full: the correlation header is accepted and echoed.

How is success determined? the event's request id is the caller's correlation id, the echo header is present, and no trace decoration is emitted.

Why does it matter? half-trusting a malformed header would mint a request id from bytes the W3C validation rejected - the strict parser is the single gate for both the trace fields and the identity decision.

should not adopt a stale trace context of the destruction thread when none was parsed

Rationale

What is tested? the emission scope's OWNERSHIP of the trace keys - an id that was not parsed from the request must be absent from the event even when the pooled destruction thread still carries one from elsewhere.

How is success determined? the event has no trace key (a bridge's spanId included) and no trace suffix; the thread's stale values are back in place after the emission (owned for the scope only, not cleared).

Why does it matter? a stale id from another scope would join this exchange's event to a FOREIGN trace - the most misleading kind of correlation during an incident.

should restore the destruction thread's MDC after the emission

Rationale

What is tested? the overlay is scoped - the destruction thread's own MDC state survives.

How is success determined? trace keys absent on the destruction thread before the emission are absent again afterwards (removed, not leaked).

Why does it matter? destruction runs on a pooled container thread; a leaked trace id would attach THIS exchange's trace to whatever that thread logs next.

should suppress a stale bridge spanId beside the parsed trace pair

Rationale

What is tested? the bridge's local-span key is suppressed during the emission even when the exchange HAS a parsed trace pair - the module never publishes under spanId (ADR-0002).

How is success determined? the event carries the parsed traceId/parentSpanId and no spanId; the thread's stale bridge value is back in place afterwards.

Why does it matter? a stale local-span id beside the caller's context would join the event to a foreign span and read as if this module had measured it.

should use the traceparent trace id as the request id and suppress the echo

Rationale

What is tested? the identity decision of ADR-0002 - a conformant traceparent's trace id IS the request id, a caller-supplied X-Correlation-Id is ignored, and NO X-Correlation-Id response header is written.

How is success determined? endpoint_request_id equals the trace id in MDC and message; the response carries no correlation header although the request supplied one.

Why does it matter? a request logger must be observationally neutral - on a traced exchange the wire already carries the identity, and echoing a second, private id would make enabling the logger visible in the communication.

RequestLoggingFilterUndertowIntegrationTest

8 tests.

should discard buffered output replaced by sendError and log no stale body on Undertow

Rationale

What is tested? the sendError boundary on Undertow - the controller writes into the buffer and then replaces the response via sendError(503); the buffer discard and the wrapper's capture discard must stay in lockstep, and Undertow's error rendering must bypass the tee.

How is success determined? the client sees the 503 error rendering (never the discarded bytes), and the WARN/failure event carries NO endpoint_response_body.

Why does it matter? sendError's buffer-clearing is exactly the spec corner where engines could diverge - a divergence here logs bodies the client never received. Given/

should echo the correlation id on Undertow except on its rebuilt error response

Rationale

What is tested? the identity contract on the third engine - and a PINNED DEVIATION: the traceless echo set at filter entry survives Tomcat's and Jetty's error dispatch, but Undertow's default error rendering REBUILDS the response and drops the header from the 500. That is the documented set-once residual of correlation-id-header ("downstream ... decides what the client finally sees"), surfacing as engine behavior.

How is success determined? the happy response carries the echo; the 500 does NOT; both exchanges are still logged with their (module-side) request id.

Why does it matter? operators correlating failures on Undertow-based hosts must know the id is in the LOG EVENT but not on the error response's wire - the opposite assumption reads the absence as a module bug. Given/

should log a DeferredResult error result at ERROR under Undertow's destruction model

Rationale

What is tested? a DeferredResult completed with an error result on real Undertow.

How is success determined? the client sees 500; one ERROR event with outcome failure, endpoint_async true and the deferred failure in the cause chain.

Why does it matter? Undertow is unsupported territory with its own destruction timing; the suite pins that the classification still holds there. Given/

should log a completed MVC async exchange with its body under Undertow's destruction model

Rationale

What is tested? the emission point under Undertow's destruction timing - whether it fires once after completion (Tomcat model) or per dispatch (Jetty model), the completion choreography must deliver ONE event with the completed state.

How is success determined? one INFO event with endpoint_async=true, the final status and the response body the async worker wrote - and the render-side MDC observed by the advice.

Why does it matter? the async lifecycle is where the engines diverged before (Jetty, 2026-08-30); this is the third data point for the container-agnostic completion design. Given/

should log a failing Callable at ERROR with the final 500 under Undertow's destruction model

Rationale

What is tested? the async error path on Undertow - the failure is rethrown in the ASYNC dispatch and the emission carries the FINAL rendered status.

How is success determined? client 500; one ERROR event, outcome failure, endpoint_async=true, status 500, the cause chain naming the Callable's exception.

Why does it matter? an async crash that logs "200 success" is wrong data on exactly the exchanges an operator investigates - the Jetty-found defect, pinned per engine. Given/

should log the final 500 of an unhandled exception without capturing Undertow's error body

Rationale

What is tested? the error-dispatch boundary on Undertow - an unhandled controller exception is rethrown unchanged, the engine renders the error response outside the tee, and the emission still reports the FINAL status.

How is success determined? client 500; one ERROR event, outcome failure, the original cause, status 500 - and NO endpoint_response_body.

Why does it matter? the "final status, no error body" pair depends on the engine's dispatch ordering; both halves must hold per engine. Given/

should pin that Undertow hands the wrappers to zero-argument startAsync and the body IS captured

Rationale

What is tested? the raw-async boundary on the third engine - and a PINNED DEVIATION: the Servlet spec words zero-argument startAsync() as initializing its context with the ORIGINAL request/response (Tomcat and Jetty do, so their raw workers write beside the tee), but Undertow hands the CURRENT, wrapped objects to the context - the raw worker therefore writes THROUGH the tee here.

How is success determined? the raw-async response reaches the client, the exchange event exists (completion survives the raw cycle) and - Undertow-specifically - CARRIES the body.

Why does it matter? the capture boundary for raw async is engine-defined, not module-defined; pinning each engine's actual behavior keeps the documented boundary honest and makes an engine-side change (either direction) a red build instead of a silent contract shift. Given/

should tee both bodies through Undertow's real streams and emit at request destruction

Rationale

What is tested? the baseline on the third engine - Undertow's real request/response streams flow through the tee wrappers, and Undertow fires requestDestroyed, the emission point.

How is success determined? the echoed round trip succeeds and exactly one INFO event carries both bodies exactly as they flowed.

Why does it matter? every boundary assertion below is only meaningful if the capture demonstrably WORKS on this engine in the regular case - and this suite is the tripwire for the unsupported-territory status of the WildFly engine (see the class KDoc). Given/

RequestLoggingFilterUndertowTracingIntegrationTest

2 tests.

should emit the parsed trace pair whatever bridge MDC Undertow's emission thread carries

Rationale

What is tested? the emission scope's OWNERSHIP of the trace keys on the third engine - whatever ambient bridge MDC Undertow's destruction timing places around the emission (live scope or stale leftovers), the parsed pair must win and no local spanId may ride.

How is success determined? the event carries exactly the sent traceId and parentSpanId, NO spanId key, the request id IS the trace id, and the traced exchange gets no X-Correlation-Id echo although the caller supplied one.

Why does it matter? the suppression is per-emission-thread behavior and therefore per-engine; this is the Undertow data point beside the Tomcat (stale keys) and Jetty (live scope) pins. Given/

should keep the parsed trace context across Undertow's async choreography

Rationale

What is tested? the trace context surviving the async emission path on Undertow - the completion choreography (destruction and/or onComplete backstop) must emit with the traceparent-derived pair that lives on the Exchange, whichever thread it ends on.

How is success determined? the async exchange's event carries the sent trace id as trace field AND as request id, with no spanId leaked from the bridge's worker/dispatch MDC.

Why does it matter? async is exactly where the emission thread and its ambient MDC differ most between the engines; this is the async half of the suppression pin above. Given/

RequestLoggingMetricsTest

17 tests.

The body size distributions

should measure the full body size beyond the logging capture limit

Rationale

What is tested? measuring and logging together - the tee counts past the capture cap, so the metric must show the REAL size while the logged field is truncated.

How is success determined? a 16-byte body against an 8-byte cap records 16 in the summary.

Why does it matter? a size metric silently clipped to the log capture limit would understate exactly the large payloads it exists to find.

should not surface a measure-only capture as a logged body field

Rationale

What is tested? the count-only capture (limit 0) exists for the metric alone - it must not leak into the event as an empty, truncated-looking endpoint_request_body.

How is success determined? body fields are absent although bytes were measured.

Why does it matter? this is the coupling bug the measure flags were deliberately decoupled to avoid - metrics on, logging off must not change the event shape.

should record both body sizes with the handler pattern as uri tag

Rationale

What is tested? the two size summaries for a chain that reads 5 bytes, writes 6 and records a handler pattern.

How is success determined? one sample per direction under the template tag with the exact byte counts.

Why does it matter? the size meters are tagged by template so their cardinality stays bounded; a sample under the raw path would explode the registry.

should record no sample for a body that never flowed and tag untemplated exchanges as UNKNOWN

Rationale

What is tested? the zero-sample rule and the template fallback in one exchange.

How is success determined? a GET without body and without handler pattern records nothing on the request side, and its response sample lands under the UNKNOWN uri tag.

Why does it matter? zero-byte samples would drag every average and percentile toward zero; and without the fallback, untemplated exchanges would silently record nothing at all.

should record sizes even when the level gate suppresses the event

Rationale

What is tested? metrics are independent of logging volume.

How is success determined? with the logger at ERROR a successful exchange emits no event but still records its response size.

Why does it matter? hosts routinely gate the exchange stream at WARN in production - the size signal must not vanish with it.

The correlation id source counter

should count the request id source as trace, header or generated

Rationale

What is tested? the upstream-propagation watch - which side of the identity contract (ADR-0002) each request lands on.

How is success determined? all three sources are pre-registered at zero; one request with a conformant traceparent, one with only the correlation header and one with neither count 1 each on their side.

Why does it matter? a rising generated share is the earliest signal that a gateway or sidecar stopped propagating traceparent or correlation ids - invisible in logs, where every event simply carries SOME id.

The emitted-events counter

should count a thrown chain as outcome failure

Rationale

What is tested? the events counter for a chain that throws.

How is success determined? the exception is rethrown unchanged; failure counts 1, success 0.

Why does it matter? the counter is the reconciliation ground truth; a thrown chain must be counted under the outcome the line carries.

should not count an event the level gate suppressed

Rationale

What is tested? the counter counts EMITTED events, not exchanges - that is what makes it comparable against the log index.

How is success determined? with the logger at ERROR, a successful exchange emits nothing and counts nothing.

Why does it matter? if the counter kept counting suppressed events, every reconciliation against the index would report phantom pipeline loss on any host that gates at WARN.

should pre-register all outcomes at zero and count an emitted success event

Rationale

What is tested? pre-registration (alerts must see the zeros) and the happy-path increment.

How is success determined? before any request all three outcomes read 0; after one clean exchange success reads 1 and the others stay 0.

Why does it matter? the counter is the metric-side half of the reconciliation against the log index - it must count exactly the events that were emitted, per outcome.

The open exchanges gauge

should keep counting an exchange whose destruction never fires

Rationale

What is tested? the failure mode the gauge exists for - the container never fires requestDestroyed, so the event is silently lost.

How is success determined? after a filter pass WITHOUT destruction the gauge stays at 1 and no event was emitted.

Why does it matter? nothing throws in this scenario, so the fail-open counter stays silent and the events counter has no baseline - this gauge's drifting baseline is the ONLY signal. Given/

should rise while the exchange is handled and return to zero at destruction

Rationale

What is tested? the up-down lifecycle of the gauge around one exchange, and its guard against double decrements when destruction fires twice.

How is success determined? 1 while the chain runs, 0 after destruction, still 0 (not -1) after a second destruction of the same request.

Why does it matter? the gauge is a liveness check - a value that could drift NEGATIVE on container quirks would destroy exactly the baseline the leak detection reads.

should share one metrics owner between two filters on the same registry

Rationale

What is tested? the per-registry metrics ownership - a second filter wired against the SAME registry must observe through the shared owner, not through a duplicate instance whose gauge registration Micrometer would silently ignore.

How is success determined? an exchange handled by the SECOND filter moves the registry's open-exchanges gauge to 1 mid-flight and back to 0 at destruction.

Why does it matter? with a duplicate owner the second filter's live exchanges were invisible on the gauge - exactly the wiring a host reaches by constructing filters manually.

The request body read counter

should count a body read to its end as complete under the handler pattern

Rationale

What is tested? the read-state counter for a chain that drains the request stream and records a pattern.

How is success determined? one complete under the template, zero partial, zero unread.

Why does it matter? the counter's complete share is what tells consumed bodies from ignored ones per route.

should count a body the application never touched as unread even though the client sent one

Rationale

What is tested? the one distinction neither the logged body nor the size sample can make - a body that was SENT but never READ.

How is success determined? the read counter shows unread while the size summary has no sample.

Why does it matter? an endpoint silently ignoring its payload looks identical to a bodyless request in every other signal of this module.

should count a body the application stopped reading as partial

Rationale

What is tested? the tee observes consumption only - it never probes for EOF itself.

How is success determined? reading 2 of 5 bytes and returning counts as partial, and the size sample shows the 2 bytes that flowed.

Why does it matter? a parser that bails out early is the case the counter exists to expose; a tee that drained the rest to "find out" would both lie and change the application's I/O.

should observe completion through the reader as well as through the stream

Rationale

What is tested? the read state for a chain consuming the body through getReader.

How is success determined? complete.

Why does it matter? the reader sits on the same tee stream; a separate path would count every text-reading handler as partial.

should record nothing when request body measuring is off

Rationale

What is tested? a filter without request measuring on a chain that reads the body.

How is success determined? no read-state counter exists in the registry.

Why does it matter? the measuring flag is the opt-in; a counter appearing without it would surprise a host that budgets its meter cardinality.

RequestLoggingPropertiesTest

7 tests.

Correlation id header

should accept every token character of a field name

Rationale

What is tested? the correlation-header validation against the full RFC 9110 tchar set.

How is success determined? a name using every token character is accepted unchanged.

Why does it matter? the validation must reject non-tokens without rejecting legal but unusual names; an over-strict pattern would fail a host's existing header name at start. Given/

should reject a correlation header name outside the HTTP field-name grammar

Rationale

What is tested? binding-time validation of the header NAME - the name is written onto every response, and a server adapter that validates field names rejects a non-token at runtime on every request.

How is success determined? whitespace, separators and a non-ASCII character fail construction with a message naming the property.

Why does it matter? a runtime rejection degrades the filter to an unlogged pass-through for ALL traffic while the application stays healthy - an observability outage nobody sees. Given/When/

Masking key

should redact the masking key in toString

Rationale

What is tested? the key is a secret - a properties dump (a startup log, a debug endpoint) must not print it.

How is success determined? toString carries the redaction marker, never the key.

Why does it matter? data-class toString would otherwise leak the secret into every context that prints the bean. Given/When/Then

should reject a blank masking key but accept an empty one

Rationale

What is tested? the binding-time rule - empty means unkeyed, blank is a misconfiguration.

How is success determined? whitespace fails construction naming the property; the empty default binds.

Why does it matter? a whitespace key would silently key the fingerprint with a worthless secret. Given/When/Then

Slow request threshold

should accept exactly one millisecond as the smallest threshold

Rationale

What is tested? the boundary of the threshold guard.

How is success determined? one millisecond is accepted unchanged.

Why does it matter? the logged duration has millisecond resolution, so 1 ms is the smallest threshold that can ever match a logged value. Given/

should reject a positive threshold below one millisecond

Rationale

What is tested? the resolution floor - the comparison runs at full precision, but the LOGGED duration has millisecond resolution, so a sub-millisecond threshold would flag exchanges whose logged duration reads 0 ms.

How is success determined? construction fails with a message naming the millisecond floor.

Why does it matter? a silently accepted 500us threshold escalates all traffic to WARN with endpoint_slow=true - false alerts and a log-volume surge from one innocent-looking value. Given/

should reject zero and negative thresholds

Rationale

What is tested? the slow-threshold guard at binding time.

How is success determined? Duration.ZERO and a negative duration both throw IllegalArgumentException.

Why does it matter? a zero threshold would flag every exchange as slow, a negative one none; both are configuration errors better caught at start than read off a dashboard. Given/When/

TwinContractTest

6 tests.

should pin the MDC keys to the literal twin contract

Rationale

What is tested? the MdcKeys and TraceMdcKeys literals of the servlet stack - including the bridge's spanId key.

How is success determined? the three endpoint_* keys, traceId, parentSpanId and spanId spelled exactly so.

Why does it matter? structured encoders emit MDC entries as fields by name; a drift would break the join between the two twins' lines and the trace. Given/When/

should pin the exchange and arrival message format to the literal twin contract

Rationale

What is tested? the MESSAGE half of the twin contract - the field names are locked by EndpointLogFieldTest, the message text was asserted by KDoc only (pinned in both twins).

How is success determined? a pinned exchange renders the literal messages both twins ship.

Why does it matter? plain-text appenders and the README's parity promise key on this text; a divergence in one twin would otherwise ship silently.

should pin the masking fingerprint format to the literal twin contract

Rationale

What is tested? HeaderValueMasker.DEFAULT over a fixed value - the length:hex shape with the first 64 bits of SHA-256.

How is success determined? secret-token renders as the literal 12:930bbdc51b6aed5c.

Why does it matter? a masked token must correlate across both twins and the outbound sibling Legatium, which pins the same literal. The expected value is hardcoded, not derived: the first 64 bits of SHA-256 over the UTF-8 bytes are stable across JVMs - and a format change in one twin breaks that module's literal here, forcing coordinated change. Given/When/

should pin the meter names to the literal twin contract

Rationale

What is tested? the duplicated meter-name constants, spelled out as literals - the cross-module lockstep tests cover configuration and field names, but not these.

How is success determined? every meter name matches the literal both twins ship.

Why does it matter? a renamed meter in ONE twin would split every dashboard by stack - silently. Given/When/

should pin the request body read states to the literal twin contract

Rationale

What is tested? the state tag vocabulary of endpoint.request.body.read, which both twins derive from their own BodyReadState enum.

How is success determined? the three tag values match the literals both twins ship.

Why does it matter? a dashboard splitting by state must not see complete from one stack and completed from the other. Given/When/

should pin the shared outcome vocabulary plus this stack's own disposition

Rationale

What is tested? the outcome literals of the servlet stack - the shared success and failure plus timeout.

How is success determined? the three literals match the values dashboards filter on.

Why does it matter? endpoint_outcome and the events counter's tag are the closed vocabulary every alert keys on; a renamed value would silently zero an alert. Given/When/