Test evidence¶
Generated by the Docs workflow from the Surefire results of the default (offline) test run and the rationale comments in the test sources. Do not edit by hand.
The default run executed 256 tests (0 failures, 0 errors, 0 skipped) in 18.4s, without a Kafka broker or Docker. Two further stages run deliberately outside this loop: the Testcontainers-based real-broker tests (mvn -Pintegration test) and the external-contract characterization tests (mvn -Pexternal-contract test) - see CONTRIBUTING.
| Component under test | Tests | Time |
|---|---|---|
KafkaAppenderTest |
62 | 5.3s |
ProducerPropertiesBuilderTest |
26 | 0.0s |
ResilientMessageSenderTest |
22 | 0.1s |
TopicRouterTest |
20 | 0.0s |
MessageEnricherTest |
19 | 0.2s |
TopicMappingConfigTest |
18 | 0.0s |
KafkaProducerPropertiesParserTest |
14 | 0.0s |
MicrometerKafkaAppenderMetricsTest |
11 | 0.4s |
SendDispatcherTest |
11 | 1.9s |
FallbackDispatcherTest |
10 | 0.7s |
HalfOpenThrottleTest |
10 | 0.0s |
ProducerRegistryTest |
9 | 0.0s |
TopicTableTest |
9 | 0.0s |
KafkaAppenderMetricsTest |
5 | 0.0s |
KafkaAppenderMetricsBindingTest |
4 | 1.0s |
JoranXmlConfigurationTest |
3 | 1.1s |
KafkaProducerPropertiesFuzzTest |
1 | 0.0s |
MessageEnricherFuzzTest |
1 | 0.0s |
TopicRouterFuzzTest |
1 | 0.0s |
FallbackDispatcherTest¶
10 tests.
Asynchronous dispatch¶
should deliver enqueued events to the fallback appender on the worker thread
should not block the calling thread when the fallback appender is slow
Rationale
What is tested? The defining property of FallbackDispatcher: enqueue() returns immediately even if the fallback appender is stuck in doAppend(). This is the entire reason this class exists - the Kafka I/O thread must never be held hostage by a slow downstream appender.
How is success determined? Successful if enqueue completes in under 200 ms even when the blocking appender holds the worker thread indefinitely. 200 ms is a generous upper bound; the actual time should be sub-millisecond.
Why does it matter? Without this test, a regression that synchronously called doAppend from enqueue would not be caught - and that regression would re-introduce the very Kafka-I/O-thread blocking that motivated this class.
Drop policy¶
should drop events and count them when the queue is full
Rationale
What is tested? Whether a full queue triggers the drop policy rather than blocking the caller or throwing.
How is success determined? Successful if enqueueing more events than the capacity returns false for the overflow and increments droppedEventCount. Pins down the bounded-queue contract.
Why does it matter? Unbounded growth would trade one OOM risk (Kafka I/O thread blocking) for another (heap exhaustion). The drop is the safe choice.
Shutdown¶
should count an event as dropped when the fallback appender throws
Rationale
What is tested? Whether an event whose doAppend throws is accounted as dropped instead of silently vanishing - the dispatcher swallows the exception (log-storm safety), but the loss itself must reach the operator's counter.
How is success determined? Successful if the dropped count reaches exactly 1 for one failed event and the dispatcher keeps working afterwards.
Why does it matter? Before this contract existed, a fallback appender that throws (full disk, closed stream) lost every event without any trace in droppedEventCount - the loss metric lied precisely in the scenario it exists for.
should count the in-flight event and every queued event as dropped if shutdown times out
Rationale
What is tested? The exact loss accounting of a forced shutdown: the event the worker has already taken off the queue and is stuck delivering (the in-flight event) plus every event still queued must each be counted as dropped exactly once.
How is success determined? Successful if a dispatcher whose worker is pinned in doAppend (an uninterruptible block, surviving close()'s worker interrupt) reports exactly 5 dropped events after close(): the in-flight trigger plus the 4 queued ones. The anchor via inAppend makes the count deterministic - the worker cannot take a second event while pinned. A >= 1 assertion would let the in-flight event silently fall out of the balance (only queue drops would satisfy it).
Why does it matter? A silent loss during shutdown would mean operators trust their fallback captures everything, while in fact a slow disk at shutdown time silently discards events - for audit or error logs the in-flight one is typically the very event that triggered the shutdown investigation.
should drain remaining events when closed gracefully
should mark events enqueued after close as dropped
should not change the dropped count when closed twice
Rationale
What is tested? Whether close() is idempotent: the appender's stop() may run more than once during Logback context teardown, and each additional close() used to re-count the still-queued events as dropped.
How is success determined? Successful if the droppedEventCount observed after the first close() is unchanged after a second close(). This pins the drain-and-clear accounting: events are counted exactly once.
Why does it matter? The dropped count feeds an operator warning and a loss metric; double counting turns the primary loss-diagnostics signal into a lie precisely during shutdown investigations.
Worker death¶
should count queued events as dropped when the worker dies
Rationale
What is tested? The queue accounting of a worker death: events queued behind the dying delivery must be counted as dropped by the death handler itself, not first at some later close().
How is success determined? Successful if after the death both the in-flight and the queued event are in droppedEventCount. The latch pins the queued event behind the in-flight one deterministically.
Why does it matter? Before the fix, queued events stayed uncounted (and new ones kept being accepted) until shutdown - in a long-lived process the loss stayed invisible to operators indefinitely.
should report a worker death and count the in-flight event as dropped
Rationale
What is tested? Whether a worker killed by an Error from doAppend (only Exceptions are handled in place) is surfaced via onWorkerDeath and the event it carried is counted as dropped.
How is success determined? Successful if the hook receives the Error and droppedEventCount reaches exactly 1.
Why does it matter? Without the hook, a dead fallback worker looks like a full queue - operators would tune queue sizes instead of finding the dead thread.
HalfOpenThrottleTest¶
10 tests.
Concurrency¶
should admit at most one probe per gap when threads race
Rationale
What is tested? Whether the CAS-based slot acquisition correctly serializes concurrent callers in HALF_OPEN state.
How is success determined? Successful if N threads racing on mayAttemptProbe() at simulated t=0 yield exactly 1 admission. The contract is "at most one probe per gap window" - concurrent callers must not sneak through.
Why does it matter? The throttle sits in the hot path of every log event of a high-volume service. A race that admits 2 or 3 probes per gap would mean the throttle silently loses its guarantee under load - the precise condition (high load) where it matters most.
Edge cases¶
should allow the first probe even when the monotonic clock is deeply negative
Rationale
What is tested? Whether the "no probe yet" state is anchored to the actual clock instead of a fixed far-past sentinel. System.nanoTime has an arbitrary origin and may itself be deeply negative; with the old sentinel (Long.MIN_VALUE / 2), a clock below the sentinel made now - last negative and denied every probe forever - permanently locking the breaker out of recovery.
How is success determined? Successful if a HALF_OPEN throttle whose clock starts near Long.MIN_VALUE admits its first probe. This pins the clock-anchored initialization.
Why does it matter? The failure mode is a total, permanent logging outage after the first breaker trip - invisible in any test using a small clock.
should disable throttling when gap is zero
Rationale
What is tested? Whether gap=0 is an explicit "disable" sentinel, allowing operators to switch off the throttle without removing it from the call site.
How is success determined? Successful if a HALF_OPEN breaker with gap=0 admits every call. This makes the throttle opt-in by configuration.
Why does it matter? An operator who finds the throttle interferes with their workload needs a clean off-switch; "gap=0 = disabled" is the simplest possible UX.
should reject a negative gap at construction time
Gap timing¶
should admit only one probe per gap window under high event rate
Rationale
What is tested? Whether the throttle correctly gates when events arrive faster than the gap allows.
How is success determined? Successful if calling mayAttemptProbe 100 times within one gap window yields exactly one admission. Pins the "one probe per slot" behavior.
Why does it matter? Without this, a busy-loop of failing sends could exhaust the breaker's permittedNumberOfCallsInHalfOpenState in microseconds - the exact scenario the throttle exists to prevent.
should allow a new probe after the gap has elapsed
Rationale
What is tested? Whether the throttle correctly admits a second probe once minProbeGap has elapsed since the first. This is the "spread the probes over time" core property.
How is success determined? Successful if probe N+1 is denied just before the gap elapses and admitted just after. Pins the time threshold precisely using the injected clock.
Why does it matter? Without this guarantee, the throttle would either over-restrict (no probes ever reach the cluster again) or under-restrict (the gap is ignored).
should spread ten probes evenly when called at maximum rate
Rationale
What is tested? The intended use case: many incoming events at high rate, throttle spaces probes over time so the breaker can make its decision while probes are still being dispatched.
How is success determined? Successful if exactly 10 probes are admitted across 50ms of simulated time with a 5ms gap - one probe per 5ms slot. This is the actual contract.
Why does it matter? Pins down the real-world behavior under load, which is the entire motivation for the class.
State-based behavior¶
should always allow probes when the breaker is CLOSED
Rationale
What is tested? Whether the throttle is transparent for normal traffic (CLOSED state). Throttling normal traffic would be a regression - it would silently rate- limit production logging.
How is success determined? Successful if a hundred sequential calls all return true, with the clock not advancing. The throttle must not gate CLOSED traffic at all.
Why does it matter? The throttle's value depends on being a no-op outside HALF_OPEN; a regression here would silently reduce logging throughput in production.
should always allow probes when the breaker is OPEN
should gate probes when the breaker is HALF_OPEN
JoranXmlConfigurationTest¶
3 tests.
Declarative binding¶
should bind every documented XML element through Joran
Rationale
What is tested? Whether the complete documented XML surface - encoder, kafkaProducerProperties text, topicMapping with defaultTopic and a <mapping> entry, the three identity fields, sendQueueCapacity, includeCallerData, and <appender-ref> - reaches the appender through Joran's reflective binding.
How is success determined? Successful if the appender started and every bound value matches the XML (defaultTopic trimmed, mapping fields populated, fallback slot holding the referenced ListAppender). Any setter rename or broken adder breaks exactly here.
Why does it matter? The XML surface is the product's actual contract; without this round trip its correctness rests on naming conventions no compiler checks.
should refuse to start via XML when a required element is missing
End-to-end through the real pipeline¶
should carry an event from an XML-configured logger to the fallback when no broker is reachable
Rationale
What is tested? The full production path built purely from XML: logger -> appender -> encoder -> routing -> a REAL KafkaProducer whose send fails (no broker on localhost:1, max.block.ms=100) -> asynchronous FallbackDispatcher -> the XML-referenced fallback appender.
How is success determined? Successful if the logged event arrives in the ListAppender within the polling deadline. This proves start() assembled a working pipeline from nothing but the declarative configuration - producer construction, breaker wiring, dispatcher thread and appender-ref resolution included.
Why does it matter? It is the only test in the suite in which the operator-facing artifact (an XML file) is the sole input, exactly as deployed.
KafkaAppenderMetricsBindingTest¶
4 tests.
Binding lifecycle¶
should attach the configured common tags to every metric
should bind only once even if the context publishes refresh multiple times
Rationale
What is tested? Whether the binding is idempotent on repeated ContextRefreshedEvent firings, which can happen in tests using ContextHierarchy or in some reload-on-property-change setups.
How is success determined? Successful if, after a second context-refresh event, a single hot-path event still increments the counter by exactly one - not by two as would happen if double-binding had registered duplicate counter references.
Why does it matter? Double-binding would cause double-counting in production dashboards - a silent and nearly undetectable data-corruption bug.
should produce counter increments through a real appender hot path
Rationale
What is tested? Whether the wiring works end-to-end: binding registers the appender, hot-path events actually increment the metric, the registry sees the change.
How is success determined? Successful if calling appender.doAppend(...) after the binding has wired up moves the events.accepted counter from 0 to 1. Pins the actual data path of the integration.
Why does it matter? A regression that replaced the metrics field with a stale or null reference would still register the counters (the binding does that eagerly) but the hot path would no longer increment them.
should register the appender's counters in the registry after the context refreshes
Rationale
What is tested? Whether the binding's ContextRefreshedEvent handler discovers the appender on the root logger and invokes bindMeterRegistry on it end-to-end, such that the registry contains the appender's metric counters after the context refreshes.
How is success determined? Successful if, after the ApplicationContext refresh, the registry has a kafka.appender.events.accepted counter present. This confirms the binding wired up successfully - a stronger guarantee than just "the bean was created", because it asserts the actual outcome of bindMeterRegistry.
Why does it matter? The whole point of KafkaAppenderMetricsBinding is to spare the operator the manual binding call. A regression where the bean is registered but the listener never fires would not surface in a bean-presence test; it would surface here as a missing counter.
KafkaAppenderMetricsTest¶
5 tests.
Enum tag values¶
should have distinct lowercase dot-friendly tag values for FallbackReason
should have distinct lowercase tag values for SendOutcome
should have distinct lowercase tag values for TopicClass
Rationale
What is tested? Whether each TopicClass exposes a stable, prometheus-safe tag value. This is the dimension along which the cardinality budget is calculated, so any duplicates would silently collapse series.
How is success determined? Successful if every TopicClass has a distinct, lowercase tag and the tags match the lowercase of the enum names. The latter pins the naming convention so future enum additions stay consistent.
Why does it matter? A duplicate tag value (e.g. two classes both mapping to "audit") would cause Micrometer to merge their metrics - a silent, nearly undetectable data-corruption bug in production dashboards.
No-op implementation¶
should accept all hook calls without throwing or allocating
Rationale
What is tested? Whether the NO_OP singleton tolerates every hook call defined on the interface. This is the default state of the appender when no Micrometer registry is bound, so misbehavior here would crash production logging for users that opted out of metrics entirely.
How is success determined? Successful if every method on every enum combination runs without exception. We don't assert "no allocation" mechanically (impossible without JFR or similar), but the implementation is verified by inspection: NoOp methods have an empty body that returns Unit.
Why does it matter? The NoOp path runs on every hot-path event when metrics are not bound. A regression here (e.g. accidental allocation, accidental exception) would manifest as a CPU regression or a hot- path crash, both very expensive to diagnose in production.
should always return the same NO_OP instance
KafkaAppenderTest¶
62 tests.
Appender-ref handling¶
should accept the first appender added via addAppender as the fallback
Rationale
What is tested? Whether the AppenderAttachable contract is wired such that Joran's <appender-ref> mechanism reaches the fallback slot.
How is success determined? Successful if calling addAppender(...) stores the appender as the fallback and a subsequent failed send routes the event through it. This pins down the path for the standard <appender-ref ref="..."/> Logback syntax.
Why does it matter? Without this, users could write the standard Logback XML and find that <appender-ref> silently has no effect - the worst kind of misconfiguration because nothing breaks loudly.
should detach the appender and free the slot when detachAppender is called
should report the attached appender via iteratorForAppenders and getAppender
should warn and ignore subsequent appenders when more than one is attached
Rationale
What is tested? Whether the single-slot semantics is enforced when multiple <appender-ref> elements appear in the configuration.
How is success determined? Successful if the first addAppender call sets the fallback, the second emits a status warning identifying the ignored appender by name, and the fallback slot still holds the first appender. This pins down both the policy decision ("first wins") and its operator-visible signal.
Why does it matter? A silent acceptance of multiple appender-refs would leave operators unaware that their configuration intent is being overruled - exactly the class of silent compliance gap this whole refactor targets.
Asynchronous fallback dispatch¶
should deliver fallback events through the real worker thread
Rationale
What is tested? The full asynchronous appender -> dispatcher -> worker -> fallback chain while the appender keeps running (no stop-drain shortcut).
How is success determined? Successful if events diverted by a failing encoder arrive at the fallback appender asynchronously (bounded polling) and a subsequent stop() completes cleanly, draining the queue.
Why does it matter? A regression in the dispatcher wiring (worker not started, stop-ordering closing the dispatcher before the registry) must surface in live delivery, not only after a drain.
should freeze the event state before it crosses to the fallback worker
Rationale
What is tested? The deferred-processing contract at the asynchronous hand-off: append() must materialize the event's lazy state (here: the formatted message computed from a mutable argument) on the caller's thread, so the fallback worker can never observe a value the caller mutated after append() returned.
How is success determined? Successful if the event delivered by the real fallback worker carries the argument's value from append time, although the argument was mutated right after doAppend returned. Without the freeze this assertion races - the worker formats the message later and can see the mutation.
Why does it matter? The fallback is the last-resort audit record, delivered exactly when Kafka is unavailable; late-mutated content there misleads incident diagnosis at the worst possible moment.
Asynchronous send dispatch¶
should deliver the in-flight event to the fallback exactly once when a pinned send fails after stop
Rationale
What is tested? The exactly-once diversion guard across dispatcher AND sender: stop() claims the event pinned in an uninterruptible producer.send and diverts it (reason shutdown); when the send later unblocks by THROWING, the sender's own send-error routing must stand down instead of delivering the same event a second time.
How is success determined? Successful if the fallback recorder holds exactly one copy of the pinned event - after stop() and still after the late failure. Without the shared claim, this scenario produced a duplicate fallback line and a double fallback count.
Why does it matter? Loss accounting is the operator's trust anchor during shutdown forensics; duplicates overstate the loss and pollute the fallback file with repeated (possibly audit-grade) records.
should divert overflowing events to the fallback instead of blocking the caller
Rationale
What is tested? The overflow policy end-to-end: a full send queue (worker parked, capacity exhausted) diverts further events to the fallback appender instead of blocking the logging thread.
How is success determined? Successful if with capacity 1 and a parked worker the surplus events arrive in the (synchronous) fallback recorder while doAppend keeps returning immediately.
Why does it matter? The bounded queue is what makes the never-blocking promise safe; the fallback diversion is what keeps it loss-visible.
should keep delivering technical events while an audit send is blocked
Rationale
What is tested? The per-class isolation of the send dispatchers: a blocked AUDIT producer (stuck broker for that class) must not delay TECHNICAL delivery - the same isolation the per-class producers and breakers promise.
How is success determined? Successful if, while the AUDIT worker is provably parked in its send, TECHNICAL events still reach their producer.
Why does it matter? With a single shared worker, one stuck class would stall all logging until its breaker opens (~10 x max.block.ms); the per-class split is what bounds the blast radius.
should return from doAppend while producer send is blocked
Rationale
What is tested? The end-to-end never-block guarantee (finding H-1 in docs/assessment/CODE_ANALYSIS-2026-08-28T22-20-43.md): with the asynchronous dispatch, doAppend returns immediately even while producer.send is parked - the situation a broker outage creates for up to max.block.ms per send.
How is success determined? Successful if a doAppend issued while the dispatcher worker is provably inside the blocked send completes in far less than the block duration. The latch anchors the worker, so the assertion is deterministic, not timing-lucky.
Why does it matter? This is the missing latency assertion from finding H-3 (same report): every prior test used an auto-completing MockProducer that never blocks, so a synchronous send path stayed green.
Configuration validation¶
should refuse to start when cmdbId is blank
should refuse to start when component is blank
should refuse to start when default topic is blank
Rationale
What is tested? Whether a blank <defaultTopic> in <topicMapping> is caught during pipeline construction (via TopicRouter validation) rather than slipping through to runtime.
How is success determined? Successful if the appender stays unstarted and the status manager contains an error message referencing the blank default topic. This confirms that build-time exceptions from TopicRouter are caught and surfaced rather than silently swallowed.
Why does it matter? A blank default topic would otherwise cause every event in the hot path to fail Kafka's topic validation, after the appender had already reported successful start - a latent misconfiguration.
should refuse to start when environment is blank
should refuse to start when no encoder is configured
Debug diagnostics¶
should emit the diagnostics note when debug is enabled
Rationale
What is tested? Whether enabling <debug>true</debug> surfaces the explicit note that informs operators the flag has no per-event effect.
How is success determined? Successful if a status message says the flag is startup-only and recommends removing it. This pins down the operator-facing guidance.
Why does it matter? Operators must know that the flag affects only startup diagnostics; without the note they would either keep the flag (no harm done, but configuration debt accumulates) or expect per-record debug output that never comes.
should list the generated producer settings when debug is enabled
Rationale
What is tested? Whether debug mode surfaces the values the appender GENERATED on top of the operator's base configuration - the derived client.id and the class overrides that actually took effect - without repeating values the operator supplied themselves.
How is success determined? Successful if the generated-settings line names the derived client.id and the acks default, but neither the operator's own bootstrap.servers nor their explicitly set linger.ms (an operator value is not a generated value, even when a class default exists for the same key).
Why does it matter? Operators debug delivery issues by asking "what did the appender change?"; repeating their own configuration would bury the answer - and could leak credentials into status output.
should never repeat operator-supplied credentials in the debug output
Rationale
What is tested? Whether the generated-settings output is credential-safe: values from <kafkaProducerProperties> (which routinely carries keystore passwords and JAAS configs) must not appear in any status message.
How is success determined? Successful if no status message contains the secret value. The diff-against-base construction guarantees this; the test pins it against a refactor that switches back to dumping the full effective configuration.
Why does it matter? SECURITY.md names credential leakage through status messages as an explicit concern for this appender.
should not emit generated producer settings when debug is disabled
should not emit the diagnostics note when debug is disabled
Hot path¶
should encode and send the event when appended
should not invoke the fallback when the send succeeds
should not run any append logic when the appender is not started
Rationale
What is tested? Whether the AppenderBase isStarted gate keeps the hot path from running before start() or after a failed start. This is the safety net for the lateinit pipeline fields.
How is success determined? Successful if appending without prior start() leaves the producer untouched. Logback's UnsynchronizedAppenderBase.doAppend short-circuits when isStarted is false; if a regression removed that gate, the lateinit fields would throw UninitializedPropertyAccessException.
Why does it matter? A regression in the lifecycle gate would manifest as confusing test failures in production, where Logback's autoconfiguration path occasionally invokes appenders before fully starting them.
Hot path concurrency¶
should deliver every event when many threads append concurrently
Rationale
What is tested? Whether the unsynchronized hot path (append -> route -> classify -> enrich -> dispatch, plus the breaker/throttle gates) is actually safe under real contention - the central design claim behind extending UnsynchronizedAppenderBase - including the hand-off: many threads race into dispatch() while a single worker drains, and the bounded queue offer plus the worker loop must neither lose nor duplicate events.
How is success determined? Successful if all N x M events eventually reach the producer with no exception escaping append and nothing diverted to the fallback. The queue capacity is raised above the total so no legitimate overflow occurs; races that corrupt shared state would surface as lost events, duplicates, or exceptions.
Why does it matter? A regression introducing shared mutable per-event state into the hot path or the queue hand-off would pass every sequential test and fail only in production under load.
Hot path failure handling¶
should keep accepted and fallback counters conserved when the encoder throws
Rationale
What is tested? The eventAccepted contract on the failure path: an event that enters append but fails before routing resolves a class must still count as accepted (under the TECHNICAL default), so that accepted = dispatched + fallback holds.
How is success determined? Successful if one encoder-failing append increments both the accepted counter and the fallback counter (reason encoder.error) exactly once, both tagged technical.
Why does it matter? Before the fix, encoder failures produced fallbacks without accepted events - operational ratios showed more diversions than ingress, breaking conservation checks exactly when diagnosis mattered.
should log only the first hot path error to prevent log storms
Rationale
What is tested? Whether repeated hot-path failures are summarized as a single status-manager error, rather than one error per failing event.
How is success determined? Successful if after 100 failing appends, exactly one "Hot path error" message exists in the status manager. This pins down the AtomicBoolean-guarded one-shot error path.
Why does it matter? Without the guard, a permanently broken encoder would generate one status error per log event - flooding any operator dashboard that surfaces Logback status as health signal, and in extreme cases consuming significant CPU just on the error-formatting path.
should not throw when both the hot path and the fallback fail
should route to the fallback appender when the encoder throws
Mandatory override warnings¶
should apply the mandatory overrides of a defaultTopicClass to the single producer
Rationale
What is tested? Whether <defaultTopicClass>AUDIT upgrades the default stream itself: one producer, AUDIT client id, mandatory overrides enforced, violation warned.
How is success determined? Successful if exactly one producer exists, its client.id carries the -audit suffix, acks was forced to all despite the operator's acks=1, and the override warning reached the status manager.
Why does it matter? This is the direct path for compliance-grading the default stream - previously only reachable via a synthetic marker mapping that left a dormant TECHNICAL producer running.
should emit a status warning when a user value conflicts with a mandatory override
Rationale
What is tested? Whether mandatory-override violations from ProducerPropertiesBuilder are surfaced to operators via Logback's status manager - the addWarn wiring in start(), not just the builder-level violation records. Activated through the real configuration surface: a <mapping> classifying a topic as AUDIT.
How is success determined? Successful if a warning naming the property key, the user value, and the enforced value reaches the status manager. The warning is the only mechanism by which operators learn that their configuration intent was overruled for compliance reasons.
Why does it matter? Silent enforcement would let an operator believe their acks=1 had taken effect for audit topics, when in fact acks=all was forced. Auditors would later find a discrepancy between the documented configuration and the actual broker behavior.
should emit no override warning with the minimal configuration
should instantiate one producer per class activated by mappings
Rationale
What is tested? Whether a <mapping> with a non-default topic class activates a second producer next to the TECHNICAL fallback producer - the multi-class model reached through the real configuration surface.
How is success determined? Successful if exactly two producers exist (AUDIT + TECHNICAL) and the AUDIT one carries the per-class client.id suffix. This pins the activation path end-to-end at the appender level.
Why does it matter? The per-class producer model was previously verifiable only by driving internals directly; with the surface shipped, the activation itself is part of the operator contract.
Metrics lifecycle¶
should deregister its meters from the registry on stop
Rationale
What is tested? Whether stop() removes everything bindMeterRegistry registered - otherwise every Logback reconfiguration cycle leaks meters and leaves gauges reading the previous (closed) dispatcher's queue.
How is success determined? Successful if after stop() the registry no longer contains any kafka.appender.* meter. This pins the register/deregister symmetry of the metrics lifecycle.
Why does it matter? Meter leaks are invisible in tests and single-start deployments; they surface as slowly growing scrape payloads and misleading dashboards only after reload cycles in production.
should keep two appenders' circuit-breaker meters apart on a shared registry
Rationale
What is tested? The multi-instance scenario of finding M-5 (same report as above): two KafkaAppenders bound to the same MeterRegistry must produce distinguishable breaker meters, and stopping one must not tear down the observability of the other.
How is success determined? Successful if both appenders' state gauges coexist under distinct appender tags, and after stopping the first appender its meters are gone while the second appender's remain.
Why does it matter? Before the appender tag, the IDs collided: gauges reported only one instance, and one appender's unbind removed the meters the other was still using - operators then made breaker decisions on missing or mixed data.
should not duplicate meters when bound twice
should record breaker call outcomes through the appender-tagged timers
Rationale
What is tested? Whether the event-driven call meters actually receive the breaker's outcomes - the own binder wires Resilience4j event consumers by hand, and a wiring mistake would leave permanently-zero timers that look healthy on a dashboard.
How is success determined? Successful if a successful send increments the kind=successful call timer of this appender's breaker.
Why does it matter? A silent zero-counting timer would be worse than an absent one: operators would read "no calls" during an incident and look elsewhere.
should register appender-tagged circuit-breaker meters without resilience4j-micrometer
Rationale
What is tested? Whether the appender's own breaker binder publishes the resilience4j.circuitbreaker.* meters and stamps them with the appender tag - the tag that makes the meter IDs unique per appender instance.
How is success determined? Successful if the registry carries the state gauge, the call timers and the not-permitted counter for the technical breaker, each with appender=<name> and name=<breaker>. The metric names mirror TaggedCircuitBreakerMetrics, so existing dashboards keep working.
Why does it matter? The breaker metrics are the operator's primary broker-outage signal; this pins both their presence (now independent of the optional resilience4j-micrometer bridge) and the tag that finding M-5 in docs/assessment/CODE_ANALYSIS-2026-08-28T22-20-43.md identified as missing.
should warn when two appenders share the same name on one registry
Rationale
What is tested? The residual collision the appender tag cannot resolve: two appenders with the SAME name (the tag value) on the same registry. The binding must tell the operator that these breaker meters are not trustworthy.
How is success determined? Successful if the second bind emits the collision warning naming the affected breakers.
Why does it matter? The warning is the only remaining guard for this misconfiguration; if it silently regressed, mixed breaker data would again look healthy.
Pipeline construction¶
should give the producer a client id derived from the component
Rationale
What is tested? Whether the appender wires a per-class client.id default of tabellarium-<component>-<topicclass> into the producer configuration.
How is success determined? Successful if the created producer's properties carry that client.id. This pins down broker-side attributability: connections, quotas, and kafka.producer metrics name the service instead of Kafka's generic producer-N.
Why does it matter? Without the default, every producer in the JVM shows up as producer-N on the broker, and operators cannot tell which service (or which topic class) a connection belongs to.
should instantiate one producer for the active topic class
Rationale
What is tested? Whether the minimal configuration (only <defaultTopic>) results in a single producer instantiation for the fallback class.
How is success determined? Successful if exactly one MockProducer was created via the factory. This pins down the single-producer guarantee: existing deployments do not silently spin up four Kafka producers when the configuration only mentions one default topic.
Why does it matter? A regression where the appender always instantiated all four producer classes would quadruple network threads and buffer memory in every deployment that uses the minimal configuration - silent resource bloat that operators would not notice until it showed up in capacity planning.
should let an operator-supplied client id win
should mark the appender as started after a successful start
should sanitize the component for the client id
Rationale
What is tested? Whether characters that are unsafe in JMX object names and metric tags are replaced before the component becomes part of the client.id.
How is success determined? Successful if a component with spaces and special characters yields a client.id containing only [a-zA-Z0-9._-]. This pins down the sanitization contract of the derived id.
Why does it matter? An unsanitized client.id breaks JMX MBean registration in the Kafka client, which surfaces as confusing warnings at startup in every deployment whose component name contains a space.
should start the encoder when starting the appender
Pipeline failure reporting¶
should include the cause when debug is enabled
should withhold the cause and stack trace when debug is disabled
Rationale
What is tested? Whether a producer-construction failure reports only the exception type by default, keeping the Kafka-authored message and the stack trace behind <debug>.
How is success determined? Successful if the error names the exception class and points at the debug flag, while the exception's own message text does not appear. That text is built by the Kafka client from credential-bearing configuration and is not under this appender's control.
Why does it matter? SECURITY.md names credential leakage through status output as an in-scope concern for this appender; this is the one path where foreign text reaches the status manager.
Producer self-logging guard¶
should deliver events from application threads whose name merely contains a client id
Rationale
What is tested? Whether the guard is anchored to Kafka's producer-network-thread naming scheme instead of a bare substring match. An operator may pin a short, generic client.id; application threads whose names happen to contain it must still be logged.
How is success determined? Successful if an event from a thread named after the (short) client.id plus a suffix is delivered to the producer. With the old substring match this event would have been silently swallowed - the worst failure mode for a logging component.
Why does it matter? Silent log loss from unrelated threads is nearly undiagnosable in production; the anchored match is the guarantee that the guard only ever suppresses the producer's own logging.
should deliver events from ordinary threads
should ignore events from threads whose name contains a producer client id
Rationale
What is tested? Whether a log event originating from one of the appender's own Kafka producer threads (the client names them after the client.id) is dropped instead of being routed back into that producer.
How is success determined? Successful if neither the producer nor the fallback sees the event. This pins down the feedback-loop guard: producer-internal logging must never be shipped through the producer itself.
Why does it matter? Under broker trouble the Kafka client logs from its network thread on every failure; feeding those events back into the failing producer amplifies load and floods the fallback exactly when the system is least able to absorb it.
should not drop events when the operator sets a blank client id
Rationale
What is tested? Whether a blank operator-supplied client.id is excluded from the guard set.
How is success determined? Successful if an ordinary event is still delivered. A blank id in the guard would be contained in every thread name and silently drop all logging.
Why does it matter? client.id= (empty value) is accepted by the properties parser; without the blank-filter this valid-if-unusual configuration would turn the appender into a black hole.
Reentry guard¶
should drop a reentrant event logged synchronously from inside the send path
Rationale
What is tested? Whether a log event created on the CALLER's thread from inside producer.send is dropped instead of recursing into the appender. The Kafka 4.x client logs ApiExceptions at DEBUG on the calling thread in its synchronous doSend failure path - such an event carries the application thread's name, so the network-thread-name guard cannot catch it, and Logback's UnsynchronizedAppenderBase ships only a no-op reentry guard.
How is success determined? Successful if the producer receives exactly the application's own event and the reentrant "Kafka internal" event reaches neither the producer nor the fallback. Without the ThreadLocal guard this test does not merely fail - it dies in unbounded recursion (send -> log -> append -> send -> ...) ending in a StackOverflowError.
Why does it matter? With org.apache.kafka at DEBUG - the very configuration an operator turns on to diagnose broker trouble - every synchronous send failure would otherwise feed itself, holding the application thread through stacked max.block.ms waits until the stack overflows.
should keep appending normally after a reentrant event was dropped
Rationale
What is tested? Whether the reentry guard is released after each top-level append - a guard that stuck in the "inside" state would silently drop every later event on that thread.
How is success determined? Successful if consecutive top-level events on the same thread all reach the producer.
Why does it matter? The guard is ThreadLocal state manipulated in a finally block; a regression here would turn one recursion incident into a permanently silenced application thread.
Repeated start¶
should ignore a repeated start and keep the existing pipeline
Rationale
What is tested? Whether start() is idempotent. A second start() used to rebuild the whole pipeline and overwrite the references to the running one - orphaning the previous producers (network threads, buffers, MBeans), which no later stop() could reach.
How is success determined? Successful if the second start() creates no additional producers and stop() afterwards closes every producer that was ever created - i.e. nothing is leaked.
Why does it matter? Logback or Spring lifecycle quirks can call start() more than once; each duplicate call would leak a full set of Kafka producers until process exit.
Shutdown¶
should close all producers when stopping
should not throw when stopping an appender that never started
Rationale
What is tested? Whether stop() is safe to call on an appender whose start() either was not called or failed. The lateinit fields are uninitialized in that case.
How is success determined? Successful if stop() returns normally. The this::producerRegistry.isInitialized guard is the contract under test.
Why does it matter? Logback's context shutdown invokes stop() on all registered appenders. If our stop() threw UninitializedPropertyAccessException on a never-started appender, it would break the orderly shutdown of other appenders too.
should stop the attached fallback appender when stopping the KafkaAppender
Rationale
What is tested? Whether stop() releases the fallback appender's resources (file handles, worker threads). This guards against a resource leak: the AppenderAttachable contract requires detachAndStopAllAppenders to run on shutdown.
How is success determined? Successful if the fallback appender reports isStarted=false after the KafkaAppender's stop() returns. This pins the lifecycle propagation that operators need for clean Kubernetes shutdowns.
Why does it matter? A FileAppender left in started state holds an open file handle until the JVM exits, which on graceful pod shutdowns means the file is not flushed/closed and the last several seconds of logs may be lost.
should stop the encoder when stopping
Startup failure rollback¶
should close already-created producers when pipeline construction fails after them
Rationale
What is tested? The transactional startup: a failure AFTER producer creation (here: circuit-breaker wiring) must roll the created producers back instead of leaving them - network threads, buffers, MBeans - orphaned behind a never-started appender.
How is success determined? Successful if the appender refuses to start and every producer the factory created has been closed again.
Why does it matter? Before the fix, the catch path only reported the failure; partially built resources survived until some external caller happened to invoke stop() - which for a failed configuration nobody does.
should not build the pipeline when the encoder fails to start
Rationale
What is tested? Whether encoder.start() runs before any pipeline resource is allocated, so a failing encoder aborts the startup with nothing to leak.
How is success determined? Successful if the appender refuses to start, no producer was ever created, and the status manager names the encoder failure.
Why does it matter? Before the fix, encoder.start() ran last - a throwing custom encoder left producers, breakers, and daemon workers behind that only an external stop() could ever reach.
Transport security signalling¶
should not warn for the minimal configuration without graded classes
Rationale
What is tested? Whether the warning is scoped to classes that actually carry compliance mandates. TECHNICAL has none, so a plain default-topic deployment must stay quiet.
How is success determined? Successful if no cleartext warning appears for the minimal (TECHNICAL- only) configuration, which is the common case.
Why does it matter? A warning that fires for every deployment would be trained away within a week, and would be gone when it finally matters.
should not warn when the graded class is configured for SSL
should warn when a compliance-graded class ships over cleartext
Rationale
What is tested? Whether an active AUDIT class combined with an unset (i.e. PLAINTEXT) security.protocol produces a startup warning.
How is success determined? Successful if a status warning names the graded class and the security.protocol setting. The appender enforces durability for graded classes and warns when overruling the operator; staying silent about cleartext transport would be the one compliance dimension without a signal.
Why does it matter? Audit records that travel unencrypted are readable and tamperable by anyone on the network path - the operator has to learn that from the startup log, since the appender deliberately does not (and cannot) enforce TLS itself.
KafkaProducerPropertiesFuzzTest¶
1 tests.
parserUpholdsItsContract(FuzzedDataProvider)[1]
KafkaProducerPropertiesParserTest¶
14 tests.
Basic parsing¶
should parse a single property
should parse multiple properties on consecutive lines
should parse the real-world example from production XML
Rationale
What is tested? Whether the parser handles the exact format that appears in production logback configurations: XML indentation, blank lines for visual grouping, and SSL property names with multiple dots.
How is success determined? Successful if every property from the <kafkaProducerProperties> element is extracted with the expected key and (trimmed) value. This pins down compatibility with the existing config.
Why does it matter? Any regression that broke parsing of the real-world layout would render the appender unable to start in any of the existing deployments - the most visible failure mode possible.
Edge cases¶
should accept an empty value
should preserve equals signs inside values
Rationale
What is tested? Whether the parser splits only on the first '=', leaving any subsequent '=' as part of the value.
How is success determined? Successful if a SASL JAAS-config line (which contains multiple '=') round-trips exactly as written. This pins down support for real-world SSL/SASL configurations.
Why does it matter? SASL configurations are critical for production Kafka security; a parser that munged them at the second '=' would silently corrupt auth credentials with no clear error.
should return an empty map for whitespace-only input
Error cases¶
should reject a line with a malformed Unicode escape
Rationale
What is tested? Whether the parser surfaces malformed Unicode escapes as IllegalArgumentException rather than silently producing garbage.
How is success determined? Successful if a value with an incomplete \uXXXX escape (only two hex digits) causes a clear IllegalArgumentException. This pins down the only failure mode of Properties.load.
Why does it matter? Without a test, a later refactor that swallowed the IOException would lose the only diagnostic signal the operator gets for this error class.
Multi-line and special separators¶
should accept colon as a key-value separator
should accept space as a key-value separator
Rationale
What is tested? Whether whitespace works as a separator per the Java .properties spec.
How is success determined? Successful if "acks all" is parsed as key=acks, value=all. This is a behavior change from the previous custom parser, which rejected this form; the change is intentional, as it matches the established .properties convention.
Why does it matter? An operator copying a properties snippet from documentation that uses whitespace separators should not be confused by spurious errors.
should ignore lines starting with exclamation mark as comments
should join lines with a trailing backslash into a single property value
Rationale
What is tested? Whether the parser supports the standard Java .properties multi-line continuation, which is the typical layout for long SASL JAAS configurations.
How is success determined? Successful if a line ending in '\' is joined with the next, with the intermediate whitespace collapsed. This pins down JAAS-config compatibility - the entire reason for switching to Properties.load.
Why does it matter? Real-world banking Kafka configs span 5-10 lines for a single JAAS entry. A parser that broke at the first '\' would prevent production deployment in SASL-secured environments.
Whitespace and formatting¶
should ignore blank lines
should ignore lines starting with hash as comments
should trim whitespace around keys and values
MessageEnricherFuzzTest¶
1 tests.
enrichmentUpholdsItsContract(FuzzedDataProvider)[1]
MessageEnricherTest¶
19 tests.
Construction validation¶
should reject construction when cmdbId is blank
should reject construction when component is blank
should reject construction when environment is blank
Custom partitioning key extractor¶
should pass null returned by the custom extractor through unchanged
should treat a blank key returned by the custom extractor as no key
Rationale
What is tested? Whether the blank-as-null normalization in enrich() applies to custom extractors as well, not only to the default one.
How is success determined? Successful if a custom extractor that returns " " produces a null partitioning key. This confirms the contract is enforced centrally and not in the default extractor only.
Why does it matter? Otherwise a careless custom extractor could leak empty-string keys into Kafka, creating the same hot-partition pathology described above.
should treat an over-long key returned by the custom extractor as no key
Rationale
What is tested? Whether the length bound is enforced centrally in enrich(), so it also covers custom extractors - not just the default MDC one.
How is success determined? Successful if an extractor returning MAX+1 characters yields a null partitioning key. The bound has to sit at the central normalization point; enforcing it only in the default extractor would leave the custom path unbounded.
Why does it matter? A custom extractor reading, say, a user-supplied header is exactly the case where the value is attacker-influenced.
should use the custom extractor when one is provided
Default partitioning key extractor¶
should derive the partitioning key from the traceId in the MDC
should return a null partitioning key when getMDCPropertyMap returns null
Rationale
What is tested? Whether the default extractor defensively handles ILoggingEvent implementations whose getMDCPropertyMap() returns null instead of an empty map. Logback's own LoggingEvent normalizes null to emptyMap, but third-party implementations or test fakes may not.
How is success determined? Successful if an event whose getMDCPropertyMap returns null produces a null partitioning key without throwing NPE. This pins the defensive null-safety in the extractor.
Why does it matter? The ?. operator in the default extractor exists explicitly for this case; without a test, a later "simplification" could remove it and introduce a regression.
should return a null partitioning key when the MDC does not contain a traceId
should treat a blank traceId in the MDC as no key
Rationale
What is tested? Whether the default extractor distinguishes between a missing key and a present-but-blank key, and treats both the same way (no partitioning key).
How is success determined? Successful if a traceId entry of " " produces a null partitioning key. This confirms that the extractor never emits a meaningless empty key.
Why does it matter? Sending records with empty-string keys to Kafka is legal but useless - they all hash to the same partition. Treating blank as null avoids the silent hot-partition pathology that would otherwise result.
should treat an over-long traceId in the MDC as no key
Rationale
What is tested? Whether a trace id beyond MAX_PARTITIONING_KEY_LENGTH is treated as absent. The MDC is attacker-influenced whenever the application bridges an inbound header into it, and the value becomes the Kafka record key verbatim.
How is success determined? Successful if a key of MAX+1 characters produces a null partitioning key while one of exactly MAX passes through unchanged - the boundary is asserted from both sides.
Why does it matter? Without the bound, an oversized inbound header inflates every record past max.request.size; the resulting RecordTooLargeException is deliberately ignored by the circuit breaker, so the breaker never opens and every such event floods the fallback appender indefinitely.
Purity guarantees¶
should not modify the MDC map of the incoming event
Rationale
What is tested? Whether the enricher leaves the input event's MDC map untouched.
How is success determined? Successful if the event's MDC map after enrich() contains exactly the same entries it had before. This is the enricher's purity contract.
Why does it matter? Mutation of the MDC map by one appender propagates to all other appenders sharing the logger context, causing cross-talk that is notoriously hard to debug. The purity guarantee is the entire reason this component exists.
Static headers¶
should expose exactly the five documented header keys
Rationale
What is tested? Whether the set of header keys is exactly the documented set (component, cmdbId, environment, agent name, agent version) - no more, no less.
How is success determined? Successful if the headers list has exactly five entries with exactly those keys. This pins down the header contract so that downstream consumers can rely on it.
Why does it matter? An accidentally added or renamed header would silently change the Kafka record schema and could break SIEM or audit consumers that filter by header.
should include component cmdbId and environment in the headers
should include the agent name and version in the headers
should load the agent version from the build-filtered resource
Rationale
What is tested? Whether AGENT_VERSION comes from the Maven-filtered tabellarium-version.properties resource rather than a hardcoded constant that would drift from the artifact version.
How is success determined? Successful if the loaded version is neither blank nor the "unknown" fallback and starts with a digit (a real version string). On the test classpath the filtered resource exists, so the fallback firing would mean the loading is broken.
Why does it matter? Downstream consumers filter records by meta.agent.version; a header that silently reports a stale hardcoded version after a release bump would corrupt that dimension.
should return an immutable headers list
should return the same headers list instance across multiple calls
Rationale
What is tested? Whether the static headers list is built once and shared across all enrich calls, rather than rebuilt per event.
How is success determined? Successful if two independent calls return the exact same list reference. This confirms the documented no-per-event-allocation contract.
Why does it matter? Rebuilding the headers list on every log event would multiply allocations in the hot path; an explicit test pins the contract down so a later "small refactor" cannot accidentally regress it.
MicrometerKafkaAppenderMetricsTest¶
11 tests.
Appender tag¶
should attach the appender tag to every metric including the untagged ones
Rationale
What is tested? Whether the appender-name tag is applied to every metric - including the fallback queue gauges and the dropped counter, which have no other distinguishing dimensions.
How is success determined? Successful if both a per-class counter (accepted) and an otherwise-untagged counter (fallback.dropped) carry appender=audit-appender. Pins the contract that the tag is uniform across the metric inventory.
Why does it matter? The whole reason for the appender tag is to disambiguate the queue gauges in the rare multi-appender setup. If the tag were silently dropped from those very metrics, the feature would be useless precisely where it matters.
should produce distinct gauge series for two appender instances sharing a registry
Rationale
What is tested? Whether two metrics instances with different appender names register distinct gauge series on the same MeterRegistry, rather than one silently overwriting the other.
How is success determined? Successful if each instance's queue-size gauge reads its own supplier independently. Without per-appender tagging, the second register() call would be idempotent on (name, tags) and return the first instance's gauge - a silent bug where the dashboard shows half the data.
Why does it matter? The multi-appender case is the only justification for the appender tag's existence in the metric model. A regression here would not surface in any single- appender test.
should treat blank appender names as unnamed
should use the literal unnamed tag value when no appender name is provided
Common tags¶
should attach common tags to every metric
Counter increments¶
should count fallback dispatcher drops on an untagged counter
should increment accepted counter for the matching topic class
should increment fallback counter with both topic class and reason tags
Rationale
What is tested? Whether the (topicClass, reason) pair is correctly attached as two separate tags, not merged into one. This is the cardinality contract: 16 distinct series for fallback in a default deployment.
How is success determined? Successful if the registry contains exactly the series corresponding to the (audit, breaker.open) and (audit, throttle) combinations, with the right count, and the counts on the wrong combination remain at zero. This proves that the tags are independent, not collapsed.
Why does it matter? A regression that mistakenly used a single composite tag (e.g. "audit-breaker_open") would silently collapse the per-reason diagnostic capability - operators would see the right total count but no idea which gate fired.
Queue gauges¶
should expose live queue size via the supplier
Rationale
What is tested? Whether the size gauge actually reads its supplier on each scrape, instead of caching a value at registration time.
How is success determined? Successful if the supplier advances and the gauge reports the new value on a subsequent scrape. Pins the live-read behavior of Micrometer gauges, which is the contract the FallbackDispatcher relies on.
Why does it matter? A regression that cached the initial supplier value would freeze the queue-size dashboard at zero - looking healthy even while the queue is full.
should expose the fixed capacity as a constant gauge
Send-duration timer¶
should record duration with the outcome tag
ProducerPropertiesBuilderTest¶
26 tests.
Base property handling¶
should not modify the base properties map across multiple buildFor calls
Rationale
What is tested? Whether the builder leaves its input map untouched, even when called multiple times with different topic classes.
How is success determined? Successful if the original input map still equals its initial contents after the builds. This confirms that the builder is a pure function with no input mutation.
Why does it matter? If the builder mutated its input, calling buildFor multiple times (which the appender will do at startup, once per active topic class) would produce different results for the same logical input - a particularly insidious bug class.
should preserve a base property that has no matching override
should produce only default and mandatory overrides when the base is empty
Client id default¶
should derive a per-class client id from the prefix
Rationale
What is tested? Whether a configured defaultClientIdPrefix yields a distinct client.id per topic class.
How is success determined? Successful if two classes built from the same builder carry <prefix>-<lowercase class name> as their client.id. This pins down the id scheme operators will see in broker logs, quotas, and kafka.producer metrics.
Why does it matter? If two classes shared one client.id, their producers would collide on JMX MBean registration in the same JVM and their per-client broker metrics would be indistinguishable.
should let an operator-supplied client id win over the default
Rationale
What is tested? Whether an explicit client.id in the base properties survives the per-class default.
How is success determined? Successful if the built properties carry the operator's value verbatim. This pins down the putIfAbsent semantics of the default layer.
Why does it matter? Operators may rely on a fixed client.id for broker-side quotas or ACLs; silently replacing it would change broker behavior on upgrade.
should set no client id when no prefix is configured
Default overrides¶
should apply a default override when the property is not set in the base
should preserve a user-set property when a default override exists for the same key
Idempotence compatibility validation¶
should not apply the idempotence checks to classes without the mandate
should reject more than five in-flight requests when the class mandates idempotence
should reject retries zero when the class mandates idempotence
Rationale
What is tested? Whether a configuration the Kafka producer constructor would refuse anyway (idempotence requires retries > 0) is rejected here with a clear, named message instead of surfacing later as a generic "Failed to build pipeline".
How is success determined? Successful if AUDIT (mandated idempotence) with operator retries=0 throws an IllegalArgumentException naming both properties.
Why does it matter? Operators debugging a refused startup need the conflicting property named; the generic constructor failure hides it.
Mandatory overrides¶
should apply a mandatory override regardless of any base value
should record a violation when the user value conflicts with the enforced value
Rationale
What is tested? Whether a user-supplied value that disagrees with a mandatory override is recorded as a violation, with the correct details about which class, key, user value, and enforced value were involved.
How is success determined? Successful if exactly one violation is recorded with the precise expected contents. This pins down the violation reporting contract that the appender will rely on when forwarding to the status manager.
Why does it matter? The violation list is the only mechanism by which operators can learn that their configuration intent was overruled. A regression in the reporting would silently hide compliance enforcement.
should record multiple violations when multiple user values conflict with mandatory overrides
should record no violation when the user did not set a mandatory-override property
should record no violation when the user value already matches the enforced value
Max block cap¶
should apply the tighter PERFORMANCE cap
should clamp an operator value above the class cap and record a violation
Rationale
What is tested? Whether a max.block.ms above the class ceiling is clamped and surfaced. producer.send blocks the logging caller's thread for up to max.block.ms when metadata is missing or the buffer is full; the appender's documented worst-case caller latency only holds if this bound cannot be raised through configuration.
How is success determined? Successful if the built properties carry the 500 ms ceiling instead of the operator's 60000 and the overruled intent is recorded as a violation for the startup warning.
Why does it matter? Before the cap, an operator could - with Kafka's own 60 s default in mind - configure a value that lets every request thread hang for a minute per log event during a broker outage.
should clamp an unparseable value and record a violation
Rationale
What is tested? Whether garbage in max.block.ms falls back to the safe ceiling instead of reaching the Kafka client (which would refuse producer construction and take the whole appender down with it).
How is success determined? Successful if the ceiling is enforced and the discarded operator value appears in a violation, so the typo is visible at startup.
Why does it matter? The property arrives as free text from XML; a typo must degrade to a safe default with a warning, not to a dead logging pipeline.
should keep an operator value at or below the class cap
Rationale
What is tested? Whether max.block.ms behaves as a CAP, not a fixed mandate: an operator tightening the bound must win.
How is success determined? Successful if a value below the 500 ms TECHNICAL ceiling survives unchanged and produces no violation.
Why does it matter? Latency-sensitive deployments legitimately configure a lower block budget; a mandate-style enforcement would overrule the safer choice.
Per topic class enforcement¶
should enforce acks all and idempotence for AUDIT topics
should enforce acks all for FUNCTIONAL topics
should not enforce any mandatory overrides for PERFORMANCE topics
should not enforce any mandatory overrides for TECHNICAL topics
Purity guarantees¶
should return an immutable properties map
should return the same result for the same input across multiple calls
Rationale
What is tested? Whether the builder is deterministic - the same (baseProperties, topicClass) pair must yield equal results on every call.
How is success determined? Successful if two independent buildFor calls with the same arguments produce results that compare equal under data-class equality. This is the operational definition of a pure function.
Why does it matter? In Logback's startup sequence the builder may be queried multiple times (once per active topic class, plus possibly diagnostic calls). Non-determinism here would manifest as flaky tests and surprising production behavior.
ProducerRegistryTest¶
9 tests.
Closing¶
should close all producers when the registry is closed
should close the remaining producers and rethrow an aggregate when one of them throws on close
Rationale
What is tested? Whether a single producer's close-failure prevents the registry from closing the others - and whether the failure is surfaced instead of swallowed.
How is success determined? Successful if all healthy producers report closed even after one of them threw during close, AND close() rethrows one aggregated exception carrying the original cause as suppressed. The caller (the appender's stop()) turns that into a status warning; a silently-swallowed close failure would leave operators without any diagnostic for leaked producers.
Why does it matter? On shutdown in a Kubernetes pod, the registry must do best-effort cleanup. A single misbehaving producer must not cascade into a complete leak of the others - but it must not vanish without a trace either.
Construction¶
should aggregate mandatory override violations across all active topic classes
should create exactly one producer per active topic class
should pass the merged topic-class properties to the factory
Rationale
What is tested? Whether the registry actually applies the property merge for each topic class - specifically that mandatory overrides reach the factory, not the unmerged base.
How is success determined? Successful if the factory receives acks=all for AUDIT, even though the base sets acks=0. This confirms that the producer is built from the merged properties, not from the raw user input.
Why does it matter? The entire point of the registry is to enforce class-specific configurations. A regression that bypassed the builder would silently break compliance.
should reject construction when the active topic classes set is empty
Construction failure handling¶
should close already-created producers when a later factory call throws
Rationale
What is tested? Whether the registry rolls back partial initialization: when the factory throws while creating one producer, the producers created before that point are closed to avoid leaking Kafka network threads.
How is success determined? Successful if the partially-created MockProducers report as closed after the exception propagates, AND the original exception reaches the caller unchanged. This confirms the rollback path.
Why does it matter? Without rollback, a failed registry init would leak Kafka network threads, accumulating with every retry attempt at application startup. In Spring Boot's bootstrap loop that could be many retries.
Producer lookup¶
should return the producer instance that was created for the given topic class
should throw when looking up a producer for a topic class that is not active
ResilientMessageSenderTest¶
22 tests.
Asynchronous failure handling¶
should route to the fallback appender when the producer callback reports an error
Rationale
What is tested? Whether an error reported via the Kafka send callback (the only mechanism for async delivery failures - leader-not-available, network drop, broker timeout, etc.) is correctly translated into a fallback-appender call.
How is success determined? Successful if the fallback receives the originalEvent only after the test explicitly triggers the error via MockProducer.errorNext(). The deferred completion confirms that the sender does not block on the Future and depends entirely on the callback.
Why does it matter? Async-failure handling is the entire reason this sender exists; if a callback error went unhandled, the event would be lost without ever reaching the fallback - exactly the situation this sender is meant to eliminate.
Circuit-breaker poisoning protection¶
should also ignore InvalidTopicException and SerializationException
should not open the breaker on a RecordTooLargeException flood
Rationale
What is tested? Whether deterministic client-side exceptions (here: RecordTooLargeException) leave the breaker in CLOSED state regardless of how many times they occur. The breaker is an infrastructure-health signal, not a payload-validation filter.
How is success determined? Successful if 30 RecordTooLargeException callbacks in a row keep the breaker CLOSED. This is far more than the default minimumNumberOfCalls=10 and failure-rate=50% would normally tolerate, so without the ignoreExceptions wiring the breaker would have transitioned to OPEN somewhere around the 5th-10th event.
Why does it matter? An application bug that suddenly logs 2 MB stacktraces could otherwise silently freeze the entire logging pipeline of its service for 30 seconds. This is the exact protection the ignoreExceptions list provides; without a test that pins it down, a refactor of the config builder could easily drop the list and re-introduce the vulnerability.
should open the breaker on a TimeoutException flood
Rationale
What is tested? The complement of the previous test: transient infrastructure exceptions DO count toward the failure rate, as they should.
How is success determined? Successful if 20 TimeoutExceptions in a row open the breaker. This is the case the breaker exists for.
Why does it matter? Pins the complement of ignoreExceptions: anything not on the list must still be observed. A regression that added too many exceptions to ignoreExceptions would silently disable the breaker entirely.
Half-open throttle¶
should admit a new probe after the gap has elapsed in HALF_OPEN
Rationale
What is tested? The complement of the previous test: once enough time has passed, the throttle allows the next probe through.
How is success determined? Successful if two events separated by exactly the gap both reach the producer. Pins the "spread probes over time" core property end-to-end.
Why does it matter? A regression that made the gap "lock once, deny forever" would still pass the previous test but break the actual goal - spreading probes, not blocking them outright.
should disable throttling entirely when probe gap is zero
should not throttle events when the breaker is CLOSED
Rationale
What is tested? Whether normal-traffic logging (breaker in CLOSED state) is unaffected by the half-open throttle. A regression here would mean the throttle silently rate-limits production logging.
How is success determined? Successful if a hundred rapid sends all reach the Kafka producer. The CLOSED-state pass-through is the most important invariant of HalfOpenThrottle and is asserted here end-to-end through the sender.
Why does it matter? An operator would never enable a half-open throttle if it could accidentally restrict normal traffic. The pass-through in CLOSED is what makes the throttle safe by default.
should route excess events to fallback in HALF_OPEN within the probe gap
Rationale
What is tested? Whether the throttle correctly limits probe admissions in HALF_OPEN state. When 5 events arrive within the same gap window, only the first becomes a probe; the remaining 4 must be routed to the fallback without consuming Resilience4j permissions.
How is success determined? Successful if exactly 1 record reaches the producer and 4 reach the fallback. This pins the "one probe per gap" core behavior at the sender integration level (not just the throttle unit level).
Why does it matter? Without this integration test, a regression that disabled the throttle wiring in the sender would still pass the HalfOpenThrottleTest in isolation but cause the actual high-volume problem in production.
Metrics instrumentation¶
should report a dispatched event with success outcome on a clean send
should report fallback with BREAKER_OPEN reason when the breaker is open
Rationale
What is tested? Whether the sender reports the correct fallback reason when the breaker denies the permission. This is the operator's primary signal for "Kafka is unreachable right now".
How is success determined? Successful if the captured event sequence shows exactly one fallback with reason "breaker.open" and zero dispatched. Pins the reason wiring against accidental swaps.
Why does it matter? The reason dimension is the entire point of the cardinality budget; if reason values are swapped the dashboard becomes misleading instead of empty (the worst kind of bug).
should report fallback with SEND_ERROR reason on async producer failure
should report fallback with THROTTLE reason when the half-open gap is not elapsed
Open circuit handling¶
should route to the fallback appender when the circuit is open
should silently drop the event when the circuit is open and no fallback is configured
Successful send¶
should attach the enrichment headers to the record as UTF-8 bytes
Rationale
What is tested? Whether all entries from the enrichment's header list land on the Kafka record as proper headers with UTF-8-encoded values.
How is success determined? Successful if the record's headers reproduce the enrichment list exactly when each value is decoded as UTF-8. This pins down the contract that downstream consumers can rely on header names being plain strings and values being UTF-8 byte arrays.
Why does it matter? Header semantics are what tells downstream systems (SIEM, audit ingestion) which record came from which service in which environment. A regression here would silently corrupt downstream filtering.
should leave the record key null when the enrichment has no partitioning key
should map the enrichment partitioning key to the record key as UTF-8 bytes
should send a record to the producer for the given topic class
Synchronous failure handling¶
should route to the fallback appender when the producer send throws synchronously
should silently drop the event when send throws and no fallback is configured
Topic-class isolation¶
should not affect one topic class circuit breaker when another transitions open
Rationale
What is tested? Whether opening the AUDIT circuit leaves TECHNICAL sends still flowing through to the producer.
How is success determined? Successful if a TECHNICAL send reaches the TECHNICAL producer while the AUDIT breaker is open. This pins down the per-class isolation: a stuck audit broker does not throttle technical-log delivery.
Why does it matter? In production a single misbehaving topic must not cascade into a complete logging blackout. The isolation is the entire reason for having one breaker per class instead of one global breaker.
should use a topic-class-specific circuit breaker name
SendDispatcherTest¶
11 tests.
Asynchronous dispatch¶
should deliver dispatched events to the send action in FIFO order
should mark the worker thread with the reentry guard
Rationale
What is tested? Whether the worker carries the appender's reentry-guard ThreadLocal. The Kafka client logs synchronously on the producer.send caller - which is the worker now - and append() must drop those events via the guard instead of feeding them back into the queue.
How is success determined? Successful if the guard reads true inside the send action.
Why does it matter? Without the marking, Kafka-DEBUG self-logging would re-enter the pipeline from the worker thread - no longer as unbounded recursion (fixed as finding H-2 in docs/assessment/CODE_ANALYSIS-2026-08-28T22-20-43.md), but as a feedback loop that amplifies during broker trouble.
should not block the calling thread while the send action is parked
Rationale
What is tested? The defining property of SendDispatcher and the heart of finding H-1 in docs/assessment/CODE_ANALYSIS-2026-08-28T22-20-43.md: dispatch() must return immediately even while the send action is blocked (the real producer.send may park for up to max.block.ms when metadata or buffer space is missing).
How is success determined? Successful if dispatch of a second event completes in far less than the time the first event's send is parked. A latch pins the send action deterministically; 200 ms is a generous bound for an O(1) queue offer.
Why does it matter? This is the latency assertion the defect analysis found missing (finding H-3, same report): a regression that runs the send action on the caller again would make every logging thread stall for max.block.ms per event during a broker outage.
Diversion claim¶
should let the send action stand down when the shutdown divert already claimed the item
Rationale
What is tested? The exactly-once contract between a forced close() and the send action's own error routing: the PendingSend's claim is handed to the sender (ResilientMessageSender uses it before every fallback diversion), so whoever claims first diverts alone.
How is success determined? Successful if, after close() diverted the pinned in-flight item with reason shutdown, the send action's later claim attempt returns false - modelling the sender finding the diversion already taken.
Why does it matter? This is the dispatcher-level pin for the duplicate-delivery scenario: without the shared claim, the same event would reach the fallback twice on exactly this timeline.
Metrics wiring¶
should register the send queue gauges for its topic class
Overflow policy¶
should divert to the fallback with reason queue-full when the queue is full
Rationale
What is tested? The bounded-queue contract: a full queue never blocks the caller - the event diverts to the fallback and is counted under reason QUEUE_FULL.
How is success determined? Successful if, with the worker pinned in a send and capacity 1, surplus dispatches land in the fallback recorder and the metric carries QUEUE_FULL. The latch anchors the worker so the queue state is deterministic.
Why does it matter? Blocking here would resurrect the caller-blocking send through the back door (finding H-1 in docs/assessment/CODE_ANALYSIS-2026-08-28T22-20-43.md); silent dropping would lose events without the operator's escape hatch.
Shutdown¶
should divert events dispatched after close to the fallback
should divert the in-flight and queued events to the fallback when the drain times out
Rationale
What is tested? The forced-shutdown accounting, the send-path analogue of finding M-3 in docs/assessment/CODE_ANALYSIS-2026-08-28T22-20-43.md: the event the worker is stuck sending plus everything still queued must divert to the fallback exactly once, tagged SHUTDOWN.
How is success determined? Successful if, with the worker pinned uninterruptibly in the send action, close() diverts exactly the in-flight event plus the queued ones - deterministic because the pinned worker cannot take a second item.
Why does it matter? On a pod shutdown with a hanging broker connection, precisely these events would otherwise vanish without fallback or count.
should drain the queue by sending when closed gracefully
Rationale
What is tested? Whether close() lets the worker finish delivering what is already queued - the producers are still open at that point in the appender's stop sequence, so draining by SENDING is both possible and the loss-free choice.
How is success determined? Successful if all events dispatched before close() reach the send action and nothing lands in the fallback.
Why does it matter? A close that discards the queue would turn every ordinary shutdown into avoidable log loss.
Worker death¶
should divert queued and later work instead of stranding it after a worker death
Rationale
What is tested? Whether a worker death transitions the dispatcher out of the accepting state: events already queued behind the dying item must be diverted by the death handler, and a dispatch after the death must divert on the caller instead of filling a queue no worker will ever drain.
How is success determined? Successful if the in-flight, the queued, and the post-death event all reach the fallback, every one counted with reason send.error. The latch pins the queued event behind the in-flight one deterministically.
Why does it matter? Before the fix, running stayed true after a worker death - up to the full queue capacity could strand silently until shutdown, and the loss surfaced only once the queue filled as misleading queue.full diversions.
should report a worker death and account for the in-flight item
Rationale
What is tested? Whether a worker killed by an Error (which the delivery loop deliberately does not catch) is surfaced via the onWorkerDeath hook and whether the item it was carrying is diverted instead of vanishing.
How is success determined? Successful if the hook receives the Error and the in-flight event lands in the fallback exactly once.
Why does it matter? A silently dead worker degrades the class to permanent queue.full diversion that reads like a slow broker; the hook is what lets the appender tell operators the real cause.
TopicMappingConfigTest¶
18 tests.
Default topic setter¶
should accept subsequent setter calls and use the last value
should default to an empty default topic
should trim leading and trailing whitespace when set
Rationale
What is tested? Whether the setDefaultTopic setter (generated by Kotlin from the var defaultTopic declaration) trims its input. Joran passes the raw text content of the XML element, which typically carries indentation whitespace.
How is success determined? Successful if setting " topic.name " yields "topic.name" with no leading or trailing whitespace. This pins down the contract that the TopicRouter's KAFKA_TOPIC_PATTERN validation relies on - Kafka topic names with whitespace would be silently truncated by the broker.
Why does it matter? Without trimming, a developer who indents the XML for readability would get " topic.name " as the literal topic name, which fails TopicRouter validation later. Better to trim quietly than to fail with a confusing error message at startup.
Marker mappings¶
should accept a mapping that names the default topic with the same class
should accept two markers sharing one topic with the same class
should classify the default topic via defaultTopicClass
Rationale
What is tested? Whether <defaultTopicClass> changes the class of the default topic (and thereby of every unmapped topic) without requiring a synthetic marker mapping.
How is success determined? Successful if with defaultTopicClass=AUDIT the table classifies the default topic as AUDIT, the fallback class is AUDIT, and - with no mappings - AUDIT is the only active class (no dormant TECHNICAL producer). This pins the direct configuration path for default-stream compliance grades.
Why does it matter? Before this element, upgrading the default stream to AUDIT required a synthetic marker mapping naming the same topic - and left an unused TECHNICAL producer running.
should reject a mapping that assigns the default topic a conflicting class
Rationale
What is tested? Whether a <mapping> naming the default topic with a class contradicting <defaultTopicClass> fails at startup.
How is success determined? Successful if toTopicTable throws and names both classes. Marker-less events and mapped events landing on the same topic must never diverge in delivery guarantees.
Why does it matter? Silently letting one side win would give part of the default stream weaker (or different) guarantees than the operator declared - a compliance discrepancy invisible at runtime.
should reject an unknown defaultTopicClass with a named error
should reject an unknown topic class with a named error
should reject the same marker mapped twice
Rationale
What is tested? Whether a marker collision fails at startup instead of silently keeping the last mapping.
How is success determined? Successful if toTopicRouter throws and names the duplicated marker. Map.associate would otherwise silently drop one mapping - an event stream quietly landing on the wrong topic.
Why does it matter? A silently dropped AUDIT mapping is a compliance incident, not a configuration nuance.
should reject the same topic assigned two different classes
should route a mapped marker to its topic and classify the topic
Rationale
What is tested? The end-to-end effect of a <mapping> element: the marker routes to the topic, and the topic carries the configured class - through the same toTopicRouter/toTopicTable path the appender uses.
How is success determined? Successful if the router resolves the marker to the mapped topic and the table classifies that topic with the mapped class, while unmapped topics stay on the TECHNICAL fallback.
Why does it matter? This is the activation path of the entire four-class compliance model; a silent regression here would strip AUDIT topics of their mandatory overrides without any startup signal.
should trim whitespace on all entry properties
Topic router construction¶
should build a TopicRouter routing all events to the configured default
should propagate TopicRouter validation failure when default topic is blank
Rationale
What is tested? Whether configuration errors surface eagerly at toTopicRouter() rather than being deferred to later runtime calls - letting the KafkaAppender fail fast at start() with a clear cause.
How is success determined? Successful if calling toTopicRouter() on a config with a blank defaultTopic throws IllegalArgumentException. This confirms that the validation pipeline runs end-to-end at startup.
Why does it matter? A silent fallback to "" as the default topic name would cause every log event to fail Kafka's topic-name validation at send-time, in the hot path, after the appender had already reported successful start - exactly the kind of latent misconfiguration that the audit's eager-validation principle targets.
Topic table construction¶
should build a TopicTable that resolves any topic to the fallback class
should build a TopicTable whose active classes contain only TECHNICAL
Rationale
What is tested? Whether the table's activeTopicClasses set reflects the minimal configuration: only the fallback class is active, no others. This drives ProducerRegistry to instantiate exactly one Kafka producer.
How is success determined? Successful if activeTopicClasses contains TECHNICAL and nothing else. This pins down the single-producer semantics expected for the minimal configuration.
Why does it matter? If extra classes leaked into the active set, the ProducerRegistry would instantiate four KafkaProducers at startup - four times the network threads and buffer memory, for no benefit, in a configuration that operationally needs only one.
should build a TopicTable with TECHNICAL as the fallback class
TopicRouterFuzzTest¶
1 tests.
validationAndRoutingUpholdTheirContract(FuzzedDataProvider)[1]
TopicRouterTest¶
20 tests.
Construction validation¶
should accept a topic name at exactly Kafka's maximum length
should accept construction when the marker mappings are empty
Rationale
What is tested? Whether an empty marker map is a valid configuration.
How is success determined? Successful if no exception is thrown and the resulting router falls back to the default topic for every input. This confirms that an "everything to default" configuration is supported.
Why does it matter? Some deployments only need a single fall-through topic without any marker-based routing; rejecting that configuration would be over-strict.
should reject construction when a mapped marker name is blank
should reject construction when a mapped topic name contains characters not permitted by Kafka
should reject construction when a mapped topic name is blank
should reject construction when a topic name exceeds Kafka's maximum length
should reject construction when the default topic contains characters not permitted by Kafka
should reject construction when the default topic is a reserved Kafka name
Rationale
What is tested? Whether Kafka's reserved topic names "." and ".." are rejected at construction. They pass the character-set pattern but the broker refuses them - and the resulting InvalidTopicException is deliberately ignored by the circuit breaker, so a reserved name that survived startup would silently divert every event to the fallback while the pipeline reports healthy.
How is success determined? Successful if both "." and ".." throw IllegalArgumentException at construction. This closes the validate-eagerly contract.
Why does it matter? The failure mode is permanent silent log loss after a clean startup - the exact latent misconfiguration eager validation exists for.
should reject construction when the default topic is blank
Default topic fallback¶
should return the default topic when no marker name matches the configured mappings
should return the default topic when the marker list is empty
Marker hierarchy¶
should not follow references of references when resolving the topic
Rationale
What is tested? Whether hierarchical resolution descends recursively through references of references, or stops at one level deep.
How is success determined? Successful if a transitively referenced marker (TOP -> MID -> AUDIT) does NOT resolve to the AUDIT topic. This confirms single-level resolution.
Why does it matter? Deep traversal would risk infinite recursion on cyclic marker references and add complexity for negligible practical benefit. The single-level contract must be pinned.
should prefer a direct match over a hierarchical match when both are present
Rationale
What is tested? The resolution order when the top-level marker itself is directly mapped, but it also references another mapped marker.
How is success determined? Successful if the direct mapping wins over the hierarchical one; this confirms the documented "direct first, hierarchical second" rule.
Why does it matter? Without this guarantee, the routing would depend on the iterator order of the marker references, which is not stable across SLF4J versions.
should resolve via a referenced marker when the top-level marker has no direct mapping
Multiple markers¶
should fall back to the default topic when none of the markers match
should return the topic of the first marker that matches when several markers are present
should skip earlier non-matching markers and return the topic of a later matching marker
Single marker direct match¶
should distinguish between markers by exact case
Rationale
What is tested? Whether marker name matching is case-sensitive.
How is success determined? Successful if 'audit' (lowercase) does not match the configured 'AUDIT' (uppercase) and falls back to the default topic. This confirms strict case sensitivity.
Why does it matter? Case-insensitive matching would cause confusion and accidental fan-out between topics; an explicit test pins down the deliberate strict-case contract.
should not trim whitespace from marker names when matching
Rationale
What is tested? Whether the router silently trims whitespace from marker names before comparing them to the configured map keys.
How is success determined? Successful if a marker named 'AUDIT ' (trailing space) does NOT match a configured key 'AUDIT'. This confirms that input normalization is the caller's responsibility, not the router's.
Why does it matter? The router's contract states exact-string matching. Sneaking in defensive trimming would hide configuration bugs upstream (where they should be caught and rejected).
should return the mapped topic when a single marker matches by name
TopicTableTest¶
9 tests.
Active topic classes¶
should always include the fallback class even when no topic maps to it
Rationale
What is tested? Whether the fallback class is always part of activeTopicClasses, even when no explicit topic was mapped to it.
How is success determined? Successful if activeTopicClasses contains the fallback class in a configuration where only an unrelated class is explicitly mapped. This pins down the contract that ProducerRegistry will always have a fallback producer available.
Why does it matter? Without the fallback class in activeTopicClasses, the ProducerRegistry would not instantiate a producer for it, and any lookup via classFor() for an unmapped topic would later resolve to a class with no producer - IllegalStateException at log time. The set must close over all classes that classFor() could ever return.
should include all classes that have at least one topic mapped
should not include classes that have no topic and are not the fallback
Construction validation¶
should accept an empty topic mapping
should reject construction when any topic name is blank
Immutability¶
should not be affected by subsequent mutations of the input map
Rationale
What is tested? Whether the table captures a defensive copy of the input map at construction time, so that the caller can safely mutate the original afterwards without affecting the table's behavior.
How is success determined? Successful if adding an entry to the original mutable map after construction does not change classFor() results. This confirms the Map.copyOf defensive-copy contract.
Why does it matter? Joran's configuration path typically populates a mutable map and passes it to the appender. If the table held a reference instead of a copy, later configuration changes (in some hot-reload scenarios) would silently retag topics.
Topic class lookup¶
should default the fallback class to TECHNICAL when not configured
should return the configured class for a known topic
should return the explicit fallback class for an unknown topic