[RESEARCH-0013] Compono.Logging Testing-Support Design Research¶
Status: Done (research/design recommendation, including a pre-ADR empirical validation pass — §12 — covering scope-provider behavior, structured-state extraction across both logging call styles, stage-6 provider precedence, and CallVerifier verification ergonomics; no ADR written yet — stopping here for review per direction, matching design-decisions.md's rule that a deep dive's brainstorm/research precedes drafting)
Feeds: ADR-0055 (Proposed) — Compono.Logging: First-Class Microsoft.Extensions.Logging Testing Support.
Product direction (given, not re-litigated here): first-class Microsoft.Extensions.Logging testing support is an Accepted Compono product requirement. This document does not evaluate whether Compono.Logging should exist — only what its smallest strong API should look like.
1. Relevant current Compono architecture¶
- Resolution pipeline (
docs/architecture/current/provider-pipeline.md, ADR-0010): a fixed 9-stage order. Stages 4 (configuration rules), 5 (semantic value providers), 6 (test-double providers), and 7 (built-in providers) are the only extensible ones. Stages ⅚ are the only ones a public package can register into (builder.AddSemanticProvider(...)/builder.AddTestDoubleProvider(...)), via ADR-0024'sICompositionValueProvider:
public interface ICompositionValueProvider
{
CompositionProviderResult TryProvide(
in CompositionProviderRequest request,
ICompositionContext context);
}
A provider returns NotHandled for anything it doesn't claim (never throws for an ordinary "not mine" case — exceptions are reserved for actual bugs). Provider order within a stage is registration order — whichever of two providers that could both claim the same type is registered first wins.
-
CompositionProviderRequest.RequestedTypeis always closed — composition requests are never for an open generic definition (docs/adr/0024-public-provider-extensibility-model.md"Open generic behavior. Does not arise").ILogger<OrderService>reaches a provider as an ordinary closed genericType; no open-generic-registration machinery is needed to claim it. A provider matches it exactly the wayNSubstituteProvidermatches any interface — a staticTypecheck (RequestedType == typeof(ILogger)orRequestedType.IsGenericType && RequestedType.GetGenericTypeDefinition() == typeof(ILogger<>)). -
Existing precedent for "specialized framework abstraction gets its own package" —
Compono.Http(ADR-0051, RESEARCH-0009): a hand-written, reflection-free type (TestHttpHandler) purpose-built for one BCL seam (HttpMessageHandler), not routed throughCompono.TestDoubles's generic double-generation. It depends only on coreCompono(forMatch<T>/CallVerifierreuse), not onCompono.NSubstituteorCompono.TestDoubles. Its verification model reuses coreCompono'sCallVerifierunmodified —Verify().Once()is the same type/API a registration handle and a generated test double both use.Compono.Loggingshould follow this shape, not the generic-double shape (see §6). -
Why
Compono.TestDoubles/Compono.NSubstitutedon't fit well:Compono.TestDoubles(docs/packages/compono-testdoubles.md, ADR-0042) generates one double per discovered interface shape, withConfigure()/Verify()built around literal/Match<T>argument matching on arbitrary members — a model built for "does my code call this member with these arguments," not "what did my code log."ILoggerhas exactly one real member (Log<TState>) whose useful test surface (level, message, exception, structured properties, scope) is encoded inside the genericTState/Func<TState,Exception?,string>pair, not visible as ordinary strongly-typed arguments a generated double's argument-matching model already handles.Compono.NSubstitutewould produce a bareSubstitute.For<ILogger<T>>()with no captured-entry model at all — usable, but every consumer would have to hand-roll capture/inspection themselves (exactly whatalexa-vox-craftalready had to do, see §5). Neither package needs to change;Compono.Loggingsimply doesn't route through either. Note this isn't a structural limitation of the generator —test/Compono.Generators.Tests/TestDoubleVerifyTests.cs:2454-2481andtest/Compono.TestDoubles.SampleTests/GenericMemberTests.csalready use a deliberatelyILogger-mirroringILoggerLikeinterface (Log<TState>(...),BeginScope<TState>(...)) as the motivating case for ADR-0044 Amendment 1's generic-method-overload support — proving the generator can emit a structurally-correct double forILogger<T>'s real generic-method shape. What it still wouldn't give for free is any decoding ofTState/Log<TState>into logging semantics (level, EventId, message, structured properties) — its verification model is generic argument-matching, not "what was logged" — so the case for a purpose-built package stands regardless. -
[Shared](docs/concepts/shared-values.md) is the existing, unmodified mechanism for "get the exact instance a composed dependency received, to assert against or configure directly" — the same mechanismCompono.NSubstituteconsumers already use ([Shared] IOrderRepository repository).Compono.Loggingneeds no new mechanism here:[Shared] ILogger<OrderService> loggeralongsideOrderService servicealready gives back the exact composed logger instance, letting an extension method on it expose captured entries. See §5 for this working in a real consumer today (manually, viaRegister<ILogger<T>>(...), without a real provider yet).
2. LayeredCraft.StructuredLogging's existing testing implementation¶
Full file: /Users/ncipollina/source/repos/layered-craft/structured-logging/src/LayeredCraft.StructuredLogging/Testing/TestingExtensions.cs (one file, ~420 lines: TestingExtensions static class, TestLogger, LogEntry, TestLogger<T>, TestScope<TState>).
Inventory:
| Type | Shape |
|---|---|
TestLogger : ILogger | List<LogEntry> LogEntries { get; }, LogLevel MinimumLogLevel { get; set; } (default Trace), BeginScope<TState>, IsEnabled, Log<TState> |
TestLogger<T> : TestLogger, ILogger<T> | Empty subclass — shares LogEntries via inheritance |
LogEntry | LogLevel, EventId, object? State (raw), Exception?, string? FormattedMessage, DateTimeOffset Timestamp |
TestScope<TState> : IDisposable | Holds TState State; Dispose() is a no-op comment: "No cleanup needed for test scope" |
TestingExtensions | GetLastLogEntry, GetLogEntry(index), GetLogEntries(level), GetLogEntriesContaining(text), GetLogEntriesWithException()/<TException>(), HasLogEntry(level, msg?), HasLogEntryWithException<T>(level?), AssertLogEntry(level, msg?), AssertLogEntryAt(i, level, msg?), AssertLogCount(n) / AssertLogCount(level, n), AssertNoLogEntries() / (level), Clear() |
What's good, worth carrying forward:
- Assertion methods throw plain
InvalidOperationException, not an assertion-framework type — already satisfies "verification must not require an assertion-framework dependency." No FluentAssertions/xUnit coupling anywhere in this file. - Reasoning in logging concepts (
AssertLogEntry(LogLevel.Warning, "retrying")) rather than rawLog<TState>mechanics — the right level of abstraction to preserve. FormattedMessage(pre-computed via the caller's ownformatter(state, exception)) is exactly right — never reformats or re-derives the message itself, so it can't diverge from what the real logger would have produced.- Simple, in-memory, synchronous, dependency-free — the right weight class for a testing package.
What's fragile or should change for a reusable Compono-native package:
LogEntry.Stateexposes the raw, untypedobject? State— exactly the concern flagged in the task brief. A consumer wanting to assert on a structured property (OrderId) has to know thatstateis (usually) a compiler-generatedFormattedLogValues : IReadOnlyList<KeyValuePair<string,object>>internal type, cast to that interface themselves, and know that the last entry's key is the magic string"{OriginalFormat}". Nothing in this file does that work for the consumer — it's punted entirely.Compono.Loggingshould do this extraction once, centrally (§7), the wayFakeLogRecord.StructuredState/GetStructuredStateValuedoes (§3).- Scopes are captured nowhere.
BeginScope<TState>constructs aTestScope<TState>and returns it, but nothing ever records that scope againstLogEntries, and nothing tracks a scope stack — two nestedBeginScopecalls have no relationship to each other from the test's point of view. This is a no-op scope implementation in every sense that matters for testing: a test cannot assert "this log happened inside scope X." The task brief's "scope behavior should be deliberately designed rather than a no-op/dummy" concern is fully justified by this file. - Not thread-safe.
LogEntriesis a plainList<LogEntry>, mutated directly inLog<TState>with no lock, and iterated directly (not snapshotted) by every query method. Concurrent logging from two threads racing intoLog<TState>is a realInvalidOperationException/data-race risk on the backing array.Compono.Http'sTestHttpHandler.Requestsalready establishes the fix pattern used elsewhere in this repo: a private lock plus.ToArray()snapshot on read. - Inheritance for shared state (
TestLogger<T> : TestLogger) works, but conflicts with this repo's own coding standard (design-decisions.mdrule 2: "prefer composition over inheritance"). It also meansTestLogger<T>is-aTestLogger, so two differentILogger<A>/ILogger<B>instances constructed independently don't share anything (correct), but a single collector object shared by composition (§7) is the more idiomatic shape here. - No
EventIdquerying, no exception-type+level combination beyond what's listed, noCategory. Minor — the query surface is a reasonable v1 shape, just incomplete relative to what real callers (§5) actually assert on (EventIdmatters more than this file assumes zero). IsEnabledis a single mutableMinimumLogLevelint comparison — simple and fine for a v1; Microsoft's ownFakeLogger.ControlLevel(§3) is a strictly richer version of the same idea, but not required to clear the bar.
3. Microsoft's official testing support: FakeLogger<T>¶
Package: Microsoft.Extensions.Diagnostics.Testing (assembly Microsoft.Extensions.Diagnostics.Testing.dll, also shipped historically as part of Microsoft.Extensions.Telemetry.Testing.dll), namespace Microsoft.Extensions.Logging.Testing. Multi-targets back to netstandard2.0/.NET Framework 4.6.2+ through net10/net11 — broad enough to sit comfortably alongside Compono's own netstandard2.1 floor (ADR-0037).
Type shape (confirmed via Microsoft Learn API reference, current as of v10.9.0):
public class FakeLogger : ILogger // non-generic base
public sealed class FakeLogger<T> : FakeLogger, ILogger<T>
- No
ILoggerFactoryrequired —new FakeLogger<T>()(ornew FakeLogger<T>(collector)) is a complete, standaloneILogger<T>. This directly answers one of the brief's open questions: Microsoft's own answer is "no factory needed for the common case." FakeLogCollector— an explicit, separately-constructible collector object.FakeLogger<T>'s constructor optionally takes one; if omitted, a fresh one is allocated. Multiple loggers can share one collector (e.g. one collector across every category in a test), or each logger can own its own (the common, simpler case).collector.GetSnapshot()returns the captured records;FakeLogCollectorOptions.FilteredLevelscan restrict what's collected at all.FakeLogRecord— the captured-entry model, and structured-logging it treats as first-class:
public class FakeLogRecord
{
public LogLevel Level { get; }
public EventId Id { get; }
public object? State { get; } // raw, same as LayeredCraft's
public Exception? Exception { get; }
public string Message { get; } // pre-formatted
public IReadOnlyList<object> Scopes { get; } // opaque scope objects, snapshot at log time
public string? Category { get; }
public bool LevelEnabled { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<KeyValuePair<string, string>>? StructuredState { get; } // flattened structured properties, when available
public object? GetStructuredStateValue(string key); // convenience lookup, e.g. "{OriginalFormat}"
}
This is the single most important piece of prior art for the brief's "should LogEntry expose raw state or first-class structured semantics" question: Microsoft's own answer keeps the raw State around (for the rare case a consumer legitimately needs it) but adds a derived, first-class StructuredState/GetStructuredStateValue surface next to it, rather than replacing one with the other. Compono.Logging should do the same — not choose between raw and structured. - Scopes is a flat, ordered snapshot of the opaque scope state objects active at the moment of the log call (outermost to innermost, or the reverse — confirm empirically, §11), not a re-flattened dictionary. A consumer wanting a scope's structured properties does the same IReadOnlyList<KeyValuePair<...>> cast the message state gets, if the scope value itself was created via BeginScope("Processing {OrderId}", id) (which also produces a compiler-generated FormattedLogValues). - ControlLevel(LogLevel, bool) — a strictly more granular version of MinimumLogLevel: individual levels can be toggled independently rather than only a single cutoff threshold. - Framework-agnostic — no xUnit/NUnit/TUnit/assertion-library reference anywhere in this package; it is pure Microsoft.Extensions.Logging plus its own types. Confirms this whole space is expected to be assertion-framework-independent, same requirement Compono has. - Dependency footprint: Microsoft.Extensions.Diagnostics.Testing pulls in the broader Microsoft.Extensions.Diagnostics/Telemetry family (it also carries fake metrics/tracing testing support, not just logging) — heavier than a Compono.Logging package would want as its own dependency if Compono is trying to keep each integration package's footprint minimal and purpose-specific (Compono.Http's own precedent: "depends only on Compono; does not add or require Microsoft.Extensions.Http"). No explicit Native AOT/trimming compatibility statement was found in the package's public docs during this research pass — worth a direct spike (§11) rather than assuming either way before depending on it.
Should Compono.Logging depend on it, wrap it, or stay independent? Recommend learn from it, stay independent — same posture Compono.Http took relative to System.Net.Http.Json/existing HTTP mocking libraries. Reasons: (a) unknown/unconfirmed AOT story, (b) heavier dependency surface than logging alone needs, © FakeLogger's own verification/query surface is minimal (GetSnapshot() plus manual LINQ) — the value Compono.Logging adds is precisely the query/verify/Compono- composition layer on top, which needs designing regardless of which concrete captor type sits underneath. The shape of FakeLogRecord (raw State + derived StructuredState + Scopes + GetStructuredStateValue) is exactly the model Compono.Logging's own captured-entry type should adopt — adopted-not-copied, per design-decisions.md's rule 5.
4. TUnit's approach¶
TUnit ships two distinct, non-overlapping logging-adjacent capabilities:
- Test-output log routing (
TUnit.Logging.Microsoft,logging.AddTUnit()) — routesILoggeroutput produced by the system under test into TUnit's own console/test-output sink, so it shows up attributed to the right test in the IDE/CI output. This is output capture, not behavior verification — explicitly out of this project's scope per the brief ("Do not expand into test-runner output capture"). TUnit.Mocks.Logging(part of the betaTUnit.Mockspackage, described as source-generated, Native-AOT-compatible, no runtime proxies) — the actually-relevant prior art:
var logger = Mock.Logger<CheckoutService>();
logger.VerifyLog()
.AtLevel(LogLevel.Warning)
.ContainingMessage("retrying")
.WasCalled(Times.Once);
This is a fluent, chained verification builder ending in a Times-based terminal call — conceptually identical in shape to Compono.Http's registration.Verify().Once() (CallVerifier.Once()/.Exactly(n)/.Never()), just spelled with an intermediate filter-builder stage (AtLevel, ContainingMessage) before the terminal call. TUnit.Mocks.Logging is evidence that a fluent level/message-filtered verification surface is a validated shape in this exact problem space (not just this project's own Compono.Http precedent) — but it's beta, evidence not specification: Compono.Logging should end its own fluent chain on the existing core CallVerifier type (already proven, already dependency-free) rather than inventing a parallel Times concept.
TUnit's docs did not surface any public structured-property or scope inspection API for Mock.Logger<T> during this pass — its verification surface appears narrower than FakeLogRecord's (level + message + call-count only). Treated as a validated shape for the fluent verify API, not a template for the captured-entry model (§3's FakeLogRecord is the stronger model there).
5. Evidence from real Compono consumer tests¶
Compono's own repo has zero real Microsoft.Extensions.Logging.ILogger usage anywhere (production or test code, excluding a stale worktree) — expected; core Compono/its integration-package tests don't themselves depend on Microsoft.Extensions.Logging. The only ILogger-named hits are the ILoggerLike generator-stress-test mirror (§1) and an unrelated private ILogger fixture interface in test/Compono.Tests/ComposerEndToEndConfigurationTests.cs:82-111 used as a generic "some interface type" for stage-3 fallback tests — coincidental naming, not logging-relevant.
Two real external consumers do, and both show the same gap:
structured-logging — hand-constructed, not composed¶
test/LayeredCraft.StructuredLogging.Tests/WarningExtensionsTests.cs already uses Compono.XunitV3 ([Compose]/[Theory]) for its ordinary parameters, but every test still does:
[Theory]
[Compose]
public void Warning_WithMessage_LogsAtWarningLevel(string message)
{
var testLogger = new TestLogger(); // manual construction — not composed
testLogger.Warning(message);
testLogger.AssertLogEntry(LogLevel.Warning, message);
testLogger.AssertLogCount(1);
}
TestLogger is new'd by hand in every single test, never composed — because there is currently no way for Compono to know it should produce a TestLogger for an ILogger parameter. This is the direct, concrete demonstration of gap #1 in the brief ("ILogger and ILogger
alexa-vox-craft — the real friction, and the real workaround¶
test/AlexaVoxCraft.MediatR.Tests/TestKit/MediatRTestProfile.cs:37-38:
// The only ILogger<T> this project's tests actually assert observable behavior
// against (AssertLogCount/HasLogEntry, from LayeredCraft.StructuredLogging.Testing) -
// a real TestLogger<T>, not a UseGeneratedTestDoubles() fake of the ILogger<T>
// interface shape (confirmed: every other ILogger<T> constructor dependency in this
// project is never itself asserted against, so the generated double suffices for
// those - no open-generic Register<ILogger<T>> rule needed).
.Register<ILogger<PerformanceLoggingBehavior>>(() =>
new TestLogger<PerformanceLoggingBehavior> { MinimumLogLevel = LogLevel.Debug })
and, further down (line 72), a second, differently-resolved ILogger<T>:
// ... no test anywhere in this project requests ILogger<SkillMediator> as its own
// theory parameter (a discovery root), so no generated test-double closure ever
// reaches it ... a plain NullLogger<T> is the right fallback, not a generated double.
.Register<ILogger<SkillMediator>>(() => Microsoft.Extensions.Logging.Abstractions.NullLogger<SkillMediator>.Instance)
And the consuming test, test/AlexaVoxCraft.MediatR.Tests/Pipeline/PerformanceLoggingBehaviorTests.cs:15-21:
[Theory]
[Compose<MediatRTestProfile>]
public async Task Handle_WithSuccessfulRequest_LogsDebugMessages(
[Shared] ILogger<PerformanceLoggingBehavior> logger,
PerformanceLoggingBehavior behavior,
...)
This is exceptionally direct evidence for the composition-mechanics question the brief asks about: [Shared] already works today for getting the composed logger instance back for assertions — no new mechanism is needed there (§1). What's missing is everything upstream of it: today, a consumer must (a) know to hand-write an exact Register<ILogger<ThatOneType>>(...) per closed generic type they care to assert against, (b) separately decide a fallback (NullLogger<T>.Instance, or accept whatever UseGeneratedTestDoubles() produces) for every other ILogger<T> in the graph that's not individually registered, and © manually pick TestLogger<T> from an entirely different package (LayeredCraft.StructuredLogging) because Compono has no logging-native type of its own. Compono.Logging's LoggingProvider (§6/§7) should make every one of these three steps unnecessary: any ILogger<T> in a composed graph should resolve to a capturing logger automatically, with [Shared] unchanged for the "I want to assert against this specific one" case, and a real NullLogger<T>-equivalent opt-out only needed for a consumer who explicitly wants a silent logger.
6. Smallest recommended Compono.Logging capability set¶
Following Compono.Http's admission shape (§1) directly, not Compono.TestDoubles's generated-double shape:
LoggingProvider : ICompositionValueProvider, registered viabuilder.UseLogging()into stage 6 (test-double providers) — claimsRequestedType == typeof(ILogger)and any closedILogger<T>, produces a capturing logger,NotHandledotherwise. Same conceptual stageNSubstituteProvider/GeneratedTestDoubleProvideroccupy; provider-order-wins-first-registered applies identically if a consumer also hasUseNSubstitute()/UseGeneratedTestDoubles()active (document explicitly: registerUseLogging()first ifILoggershould resolve to a capturing logger rather than a generic substitute).- A single non-generic captor (
CapturingLogger : ILogger) and a single generic captor (CapturingLogger<T> : CapturingLogger, ILogger<T>— or composition-based equivalent, see §7) — not source-generated, not one-type-per-interface.ILogger<T>'s only real member isLog<TState>; one hand-written generic class handles every closedT, unlikeCompono.TestDoubles, which needs per-interface generation because arbitrary interfaces have arbitrary members. CapturedLogEntry— the structured-first captured-entry model (§7), adoptingFakeLogRecord's "raw + derived" shape.- Query surface — direct
IReadOnlyList<CapturedLogEntry>inspection, no assertion framework required (§8). - Fluent
VerifyLog()ending in coreCallVerifier(§8) — the "reason in logging concepts" and "Compono-styleVerify()" requirements both land here. - Real,
IExternalScopeProvider-based scope tracking (§9) — not a no-op, reusing a BCL type rather than inventing one. - Thread-safe capture — lock + snapshot,
Compono.Http's already-established pattern (§2 point 3).
Explicitly not in v1 (no concrete evidence for any of these in §5's real consumer data, and each is a meaningfully separate feature):
ILoggerFactorycomposition (nothing inalexa-vox-craft/structured-loggingrequests a factory; every real case is a constructor-injectedILogger<T>).- Serilog/other provider-specific testing.
- Test-runner output capture (TUnit's
TUnit.Logging.Microsoftconcern, a different problem than behavior verification). - Cross-scope structured-property flattening/searching (a scope's own structured values are reachable via the same
IReadOnlyList<KeyValuePair<...>>cast as message state — no bespoke API needed for v1). FakeLogger-style per-levelControlLeveltoggling — a singleMinimumLevelthreshold (LayeredCraft's existing, sufficient shape) covers every real case seen in §5.
7. Proposed public API¶
namespace Compono.Logging;
public static class CompositionBuilderExtensions
{
public static CompositionBuilder UseLogging(
this CompositionBuilder builder, Action<LoggingOptions>? configure = null);
}
public sealed class LoggingOptions
{
public LogLevel MinimumLevel { get; set; } = LogLevel.Trace;
}
public readonly record struct CapturedLogEntry
{
public LogLevel LogLevel { get; }
public EventId EventId { get; }
public Exception? Exception { get; }
public string Message { get; } // pre-formatted, via the caller's own formatter — never re-derived
public object? State { get; } // raw TState, boxed — always present, escape hatch
public IReadOnlyList<KeyValuePair<string, object>>? Properties { get; } // non-null only when TState implements IReadOnlyList<KeyValuePair<string, object>> — confirmed (§9 spike) both FormattedLogValues (ordinary extension-method calls) and LoggerMessageState (the one shared BCL type behind every [LoggerMessage] source-generated call) satisfy this identically; one code path covers both
public string? MessageTemplate { get; } // Properties' "{OriginalFormat}" entry, surfaced by name; null if Properties is null or that key is absent
public IReadOnlyList<object> Scopes { get; } // outermost→innermost snapshot (confirmed, §9 spike — matches LoggerExternalScopeProvider.ForEachScope and Microsoft's own FakeLogRecord.Scopes ordering), active at the moment this entry was logged
public DateTimeOffset Timestamp { get; }
}
// Implemented once; works for both ILogger and every closed ILogger<T> — no per-T generation.
public sealed class CapturingLogger : ILogger { /* ... */ }
public sealed class CapturingLogger<T> : ILogger<T> { /* composes a CapturingLogger, does not inherit it — see §7 rationale */ }
public static class LoggerTestingExtensions
{
// Direct inspection - no assertion framework required.
public static IReadOnlyList<CapturedLogEntry> GetCapturedEntries(this ILogger logger);
public static CapturedLogEntry? GetLastCapturedEntry(this ILogger logger);
public static void ClearCapturedEntries(this ILogger logger);
// Fluent verification entry point - one verb, matching the real Compono.TestDoubles
// precedent (`repository.Verify().Save().Once()`), not the two-verb VerifyLog()...Verify()
// shape originally proposed here (revised per the CallVerifier ergonomics spike, §12 item 4).
public static LogVerificationBuilder Verify(this ILogger logger);
}
public sealed class LogVerificationBuilder
{
public LogVerificationBuilder AtLevel(LogLevel level);
public LogVerificationBuilder WithEventId(EventId eventId);
public LogVerificationBuilder WithException<TException>() where TException : Exception;
public LogVerificationBuilder WithMessageContaining(string text);
public LogVerificationBuilder Matching(Func<CapturedLogEntry, bool> predicate);
// Terminal methods live directly on the builder - thin one-line forwarders to core
// Compono.CallVerifier (constructed from the filtered match count right here), not a new
// counting/Times abstraction. CallVerifier itself is never exposed as an intermediate type.
public void Once();
public void Never();
public void Exactly(int times);
}
Usage — composed, no manual registration:
var composer = Composer.Create(builder => builder.UseLogging());
var service = composer.Create<OrderService>(); // OrderService(ILogger<OrderService> logger, ...)
Usage — asserting against the composed instance, via existing [Shared]:
[Theory]
[Compose]
public void RetriesLogAWarning([Shared] ILogger<OrderService> logger, OrderService service)
{
service.PlaceOrder(...);
logger.Verify().AtLevel(LogLevel.Warning).WithMessageContaining("retry").Once();
}
Usage — direct inspection:
var entries = logger.GetCapturedEntries();
var failure = entries.Single(e => e.LogLevel == LogLevel.Error);
Assert.Equal("OrderId", failure.Properties?.First().Key);
Why CapturingLogger<T> composes rather than inherits CapturingLogger (deviating deliberately from TestLogger<T> : TestLogger, §2 point 4): a shared internal LogEntryCollector object (lock + List<CapturedLogEntry> + scope-provider reference) is held by both flavors; CapturingLogger/ CapturingLogger<T> are thin ILogger/ILogger<T> façades over it. This follows design-decisions.md rule 2 (composition over inheritance for the object model) and, more concretely, means a future "share one collector across two differently-typed loggers" need (not required for v1, no evidence demands it — §6) is a constructor option on the collector, not an inheritance-shape change.
8. Composition/integration mechanics¶
UseLogging()→AddTestDoubleProvider(new LoggingProvider(options))— stage 6, exactly whereCompono.NSubstitute/Compono.TestDoublesalready register (§1). No new pipeline stage, no engine change.[Shared]needs no change — already demonstrated working today against a manually-RegisteredILogger<T>(§5); a provider-resolved value participates in shared-value scope identically to a registered or built-in one (docs/architecture/current/provider-pipeline.md's "Shared substitute reuse" note — existing engine behavior, zero new code, same asCompono.NSubstitute's own admission).- Logger category representation:
ILogger<T>'s category isT's full name by convention (matches realLogger<T>'s behavior); the non-genericILoggercaptor takes an explicitstring categorywhen a consumer constructs one directly outside composition (rare — most real cases are the generic constructor-injected form, §5). - Should
ILoggerandILogger<T>share captured state? No, by default — each composed request gets its own independentCapturingLogger/LogEntryCollector, matching Compono's independent-by-default composition model (docs/concepts/shared-values.md: "two composed values of the same type are supposed to be independent... that's the default"). A consumer wanting two logger parameters to observe the same underlying entries uses[Shared], same as any other type. ILoggerFactoryis out of scope for v1 (§6) — no real consumer evidence requests one; every real case in §5 is direct constructor injection ofILogger<T>.- Stage-6 precedence between
UseLogging()andUseNSubstitute()/UseGeneratedTestDoubles()— investigated and resolved (§12 item 3).CompositionContext.TryProviders(src/Compono/CompositionContext.cs:921-944) is a hard short-circuit: first provider in a stage to returnSuccesswins, no specificity scoring. This is the same structural ambiguity that already exists betweenNSubstituteProvider(claims any interface unconditionally) andGeneratedTestDoubleProvider(claims only types the generator actually emitted a double for) — and Compono already has a standing answer for it, not a gap: ADR-0043's "Runtime activation and precedence" section states this exact rule explicitly ("explicit registration → explicit.For<T>()rule → generated test double → NSubstitute —UseGeneratedTestDoubles()beforeUseNSubstitute()... consistent with ADR-0024's existing 'registration order' dispatch rule, made explicit... rather than left to registration-order accident. A consumer who registers both in the opposite order gets NSubstitute-first behavior... an explicit, documented consequence of registration order, not silent or diagnosed-against in v1"), and ADR-0024 states the general rule ("no priority/specificity system... no richer ordering rule exists yet because none has been needed").Compono.Loggingshould copy this exact pattern, not invent a new precedence mechanism or touch the pipeline: the eventual ADR gets its own "Runtime activation and precedence" section stating plainly thatUseLogging()must be registered beforeUseNSubstitute()/UseGeneratedTestDoubles()forILogger<T>to resolve to a capturing logger, with the reverse order documented as an explicit, accepted consequence rather than a bug.
9. Scope design — the concrete recommendation¶
Rather than inventing scope-stack tracking from scratch (LayeredCraft's no-op, §2), reuse the BCL's own public, dependency-free, AOT-safe scope mechanism: Microsoft.Extensions.Logging.LoggerExternalScopeProvider (namespace is Microsoft.Extensions.Logging, not .Abstractions, despite shipping in the Abstractions assembly — confirmed empirically, §12 item 1; implements IExternalScopeProvider, uses AsyncLocal<> internally — the same type real logging providers like the console logger delegate to).
Recommended shape: LogEntryCollector owns one LoggerExternalScopeProvider instance. CapturingLogger/CapturingLogger<T>.BeginScope<TState> calls scopeProvider.Push(state) and returns the IDisposable it hands back directly (real pop-on-dispose semantics, not a no-op). At Log<TState> time, before constructing the CapturedLogEntry, call scopeProvider.ForEachScope((scopeState, entries) => entries.Add(scopeState), scratchList) to snapshot every currently-active scope into CapturedLogEntry.Scopes. Because AsyncLocal<>-backed, this correctly flows across await points within the same logical call — required, since the code under test in every real §5 example is async.
Confirmed empirically (§12 item 1) — no longer an open assumption: LoggerExternalScopeProvider is public with a public parameterless constructor; Push/Dispose pop correctly through nested scopes ([outer,inner] → dispose inner → [outer] → dispose outer → []); ForEachScope enumerates outermost-first (3-level nesting produced [L1, L2, L3]); a side-by-side spike against Microsoft's own FakeLogger<T>/FakeLogRecord.Scopes confirmed it also orders outermost-first, so CapturedLogEntry.Scopes matches both the BCL mechanism and Microsoft's own captor without needing to reverse anything. AsyncLocal<> isolation is real, not just documented: a scope pushed inside an async method remains visible after an await in the same logical call, a sibling Task.Run started before the push does not see it (true isolation), and a sibling Task.Run started after the push does see it (expected ExecutionContext-capture-at-creation-time forward flow, not a leak).
10. AOT/trimming and dependency implications¶
- Zero reflection anywhere in this design.
LoggingProvider's match check is a staticType/Type.GetGenericTypeDefinition()comparison (already the exact patternNSubstituteProvideruses, already proven AOT-safe in this repo).CapturingLogger/CapturingLogger<T>are ordinary hand-written classes — no dynamic proxy generation (unlikeCompono.NSubstitute's necessary, accepted exception to the no-reflection default, §1/ADR-0025), no source generation needed (unlikeCompono.TestDoubles) — becauseILogger<T>'s member surface is fixed and small, one hand-written generic class covers everyT. - Structured-property extraction stays reflection-free: the
Propertiesderivation is a singlestate is IReadOnlyList<KeyValuePair<string, object>> listpattern-match — noTState-shape reflection, and confirmed empirically (§12 item 2) to be the same single pattern-match for both ordinary extension-method calls (FormattedLogValues) and[LoggerMessage]source-generated calls (LoggerMessageState— one shared BCL type reused across every[LoggerMessage]call site, not a bespoke per-call-site struct as originally guessed) — no separate code path needed for the source-gen style. - Dependency footprint:
Compono(core) +Microsoft.Extensions.Logging.Abstractionsonly (noMicrosoft.Extensions.Loggingconcrete implementation, noMicrosoft.Extensions.DependencyInjection, noMicrosoft.Extensions.Diagnostics.Testing— see §3's independence recommendation). This is the minimal footprint the wholeILoggerecosystem is built on; every ASP.NET Core/worker-service/console-host consumer already transitively referencesAbstractions. Directory.Build.props-level AOT/trimming settings already established in this repo apply unchanged —Compono.Loggingneeds no new AOT opt-out, unlikeCompono.NSubstitute's accepted reflection exception. Should followCompono.Http's csproj precedent and set<IsAotCompatible>true</IsAotCompatible>explicitly — currently the only package in the repo that sets it (coreComponoandCompono.TestDoublesleave it unset, which is silence, not a negative signal, since neither has a[RequiresDynamicCode]/[RequiresUnreferencedCode]- annotated member to gate). Setting the flag is what makes the trim/AOT analyzer actually enforce those attributes at consumer call sites rather than silently suppress the warning — worth doing here precisely because this design's core claim is "no reflection anywhere," and the flag is the mechanism that makes the analyzer verify that claim rather than take it on faith.
11. Alternatives considered and rejected¶
- Route
ILogger/ILogger<T>throughCompono.TestDoubles's generated doubles instead of a purpose-built provider. Rejected — §1 and §5 demonstrate real friction (alexa-vox-craft's explicit comment: a generated double forILogger<T>is not what's needed for the one logger a test actually asserts against; a generic double gives no message/level/structured-property inspection surface at all, only argument-matched configuration of an opaqueLog<TState>call). Compono already has one precedent (Compono.Http) for "this BCL seam deserves purpose-built support, not the generic path" — this is the second. - Depend on
Microsoft.Extensions.Diagnostics.Testing'sFakeLogger<T>directly, and build only the Compono-composition/verify layer on top. Considered seriously (§3) — rejected for v1 on two grounds: (a) unconfirmed AOT/trimming compatibility and heavier transitive dependency surface (the whole fake-metrics/fake-tracing family, not just logging); (b) it foreclosesCompono.Loggingowning its ownCapturedLogEntryshape suited exactly to Compono's own verification idioms (CallVerifierreuse,[Shared]integration) rather than adaptingFakeLogRecord's shape after the fact. Its captured-entry model is adopted (§3/§7); its package is not depended on. TestLogger<T> : TestLoggerinheritance for shared state, matchingLayeredCraft.StructuredLoggingexactly. Rejected in favor of composition over a sharedLogEntryCollector(§7) — consistent withdesign-decisions.mdrule 2, and leaves the "share one collector across two differently-categorized loggers" question open as a constructor option rather than a type-hierarchy decision, should real evidence ever demand it.- A
MinimumLevel-only filter vs.FakeLogger's per-levelControlLeveltoggling.MinimumLevel(LayeredCraft's existing shape) is kept for v1 — every real §5 use case is satisfied by a single threshold;ControlLevel's richer per-level control has no concrete demand behind it yet (matches this project's own "smallest strong API" framing). - Register
LoggingProvideras a stage-5 semantic-value provider instead of stage 6. Rejected —ILogger/ILogger<T>are interface requests receiving a purpose-built fake, the same conceptual shape stage 6's existing registrants (NSubstituteProvider,GeneratedTestDoubleProvider) already occupy; stage 5 is reserved for "pattern-matching value semantics" providers likeCompono.Bogus's member-name provider, a different kind of concern. - Two-verb
logger.VerifyLog()....Verify().Once()chain, exposingCallVerifieras a visible intermediate return type. Rejected after the CallVerifier ergonomics spike (§12 item 4) —CallVerifier(src/Compono/CallVerifier.cs:12-34) is areadonly structbuilt from an already-computedintcount plus a description, withvoid-returning terminal methods (Once/Never/Exactly); it does no filtering itself, so nothing about it demandsVerify()appear twice in one chain. The realCompono.TestDoublesprecedent (repository.Verify().Save().Once(),test/Compono.TestDoubles.SampleTests/VerificationTests.cs:21-23) uses "Verify" exactly once:Verify()is the entry point, and a filter/ member-selector chain leads straight toCallVerifier's terminal methods.Compono.Logging's public API (§7) now matches that shape —logger.Verify().AtLevel(...).WithMessageContaining(...).Once()— withLogVerificationBuilderexposingOnce()/Never()/Exactly(n)directly as thin forwarders to aCallVerifierbuilt from the filtered count, rather than returningCallVerifieras a visible type partway through the chain. This is still zero new abstraction: three one-line delegations to a struct that only has three members.
12. Pre-ADR validation spikes — results¶
All four spikes below were run empirically (real code, not reasoned from memory) before this document was updated; none required a change to the recommended architecture, and none surfaced a need for a broader cross-cutting Compono design decision beyond what's already noted for [Shared] (item 5).
LoggerExternalScopeProvideraccessibility andForEachScopeenumeration order — resolved, confirmed empirically (§9).Microsoft.Extensions.Logging.LoggerExternalScopeProvider(note: namespaceMicrosoft.Extensions.Logging, not.Abstractions, though it ships in that assembly) is public with a public parameterless constructor.Push/Disposepop correctly through nested scopes.ForEachScopeenumerates outermost-first — confirmed against 3 levels of nesting, and cross-checked directly against a realFakeLogger<T>/FakeLogRecord.Scopes, which orders outermost-first too.AsyncLocal<>isolation confirmed genuine: visible acrossawaitin the same logical call, invisible to a siblingTask.Runstarted before the push, visible to one started after (expectedExecutionContext-capture forward flow, not a leak).CapturedLogEntry.Scopesis now specified as outermost-first in §7 with no remaining uncertainty.{OriginalFormat}/ structured-state reliability across both common logging call styles — resolved, confirmed empirically (§9). Ordinarylogger.LogInformation("...", args)produces aFormattedLogValuesstate;[LoggerMessage]-source-generated calls produce aLoggerMessageStatestate — a single shared BCL type reused across every[LoggerMessage]call site (not a bespoke per-call-site struct, contrary to this document's original guess). Both implementIReadOnlyList<KeyValuePair<string, object>>(non-nullableobject, notobject?— §7'sCapturedLogEntry.Propertiessignature corrected accordingly), both carry"{OriginalFormat}"as an entry with the raw template string, and structured values are preserved as their original boxed CLR type in both paths (ints/strings round-trip via unboxing, not stringified). One uniform extraction code path (state is IReadOnlyList<KeyValuePair<string, object>> pairs) covers both call styles with no special-casing.- Stage-6 provider-precedence semantics — resolved, verdict (A) from the design-decisions.md framing in the request (§8).
CompositionContext.TryProviders(src/Compono/CompositionContext.cs:921-944) is confirmed by source to be a hard short-circuit: first provider in a stage to returnSuccesswins, no specificity scoring exists anywhere in the pipeline. This exact structural situation — a specialized provider vs. a generic fallback provider both able to claim the same type — already exists today betweenNSubstituteProvider(claims any interface unconditionally,src/Compono.NSubstitute/NSubstituteProvider.cs:31-46) andGeneratedTestDoubleProvider(claims only types the generator actually emitted a double for,src/Compono.TestDoubles/GeneratedTestDoubleProvider.cs:13-16), and Compono already has a standing,Accepted, documented answer for it: ADR-0043's "Runtime activation and precedence" section states plainly that registration order is the tiebreak, that reversing it is an explicit accepted consequence and not a bug, and ADR-0024 confirms the general rule ("no priority/specificity system... no richer ordering rule exists yet because none has been needed"). This is not a gap Compono.Logging exposes — it's an already-settled pattern the design should copy verbatim (own "Runtime activation and precedence" ADR section, §8), and importantly: no pipeline/architecture change is needed or warranted — confirming the request's instruction not to expand scope into a pipeline redesign was the right call going in. VerifyLog()/CallVerifierergonomics — resolved, API revised (§7/§11).CallVerifier's real shape (areadonly structfrom an already-computed count,void-returning terminals) and the realCompono.TestDoublesprecedent (Verify()used exactly once per chain,test/Compono.TestDoubles.SampleTests/VerificationTests.cs:21-23,30) together settle this:Compono.Logging's public API is revised fromlogger.VerifyLog()....Verify().Once()tologger.Verify()....Once(), withOnce()/Never()/Exactly(n)living directly onLogVerificationBuilderas thin forwarders toCallVerifier— matching the one-verb precedent exactly, still zero new counting abstraction.
Remaining open items for the ADR itself (not spikes — judgment calls that don't need more empirical work):
- Exact non-generic
ILoggerconstruction API — real §5 evidence is 100%ILogger<T>(constructor-injected, category = a type). WhetherCapturingLogger's standalone (non-generic) constructor needs astring categoryparameter at all forCompono.Loggingv1, or can be deferred entirely until a real non-genericILoggerconsumer case surfaces, is worth a final judgment call in the ADR rather than in this research document. [Shared]ergonomics — explicitly out of scope forCompono.Logging, flagged for its own future investigation. The proposed[Shared] ILogger<OrderService> logger, OrderService serviceusage (§7) is another concrete instance of an already-recognized[Shared]gap: a consumer sometimes needs a parameter purely to obtain the graph-owned instance for inspection, not because the parameter itself is under test.Compono.Logginguses[Shared]exactly as it exists today (§8) and does not attempt to solve this locally — e.g. noShare<T>()helper, no new attribute. This is recorded here as evidence for that broader, separately-tracked design question, not as something this package's ADR should resolve.- AOT/trimming compatibility of
Microsoft.Extensions.Diagnostics.Testingitself remains unconfirmed either way (§3/§10) — not blocking, sinceCompono.Logging's recommendation is independence from that package regardless of its AOT status; only matters if a later revision reconsiders depending on it, and should not be silently assumed either way in that hypothetical ADR's Context section.