[PLAN-0072] Compono.Logging Host-Wide Log Capture (Issue #164) + .NET 11 GA Migration¶
Status: Done
Implements: ADR-0071 (issue #164, child of #161), plus the .NET 11 GA migration, an infrastructure workstream that needs no ADR. ADR-0071 is Accepted (2026-11-10). The maintainer approved ADR-0071 and this plan, including the review-round-1 clarifications in 768af68, and B0 is closed. Architectural approval is complete. The accepted planning artifacts now live on main (merged via PR #175). Implementation is complete: B1 (.NET 11 GA, PR #176) merged, and B2-B7 (host-wide log capture) are implemented and validated on feat/164-host-wide-log-capture.
Goal¶
- Workstream B (.NET 11 GA): the repo builds, tests, packs, package-validates, and Native-AOT-validates on the .NET SDK 11.0.100 GA. It has no remaining .NET 11 preview/RC SDK or package references, and the net8.0, net9.0, net10.0, and net11.0 targets are unchanged.
- Workstream A (#164): a test can write
builder.Logging.AddComponoCapture(logs)and thenlogs.Verify().AtLevelOrAbove(LogLevel.Error).Never(), capturing every category a real Generic Host creates. Captured entries exposeCategory, and existingCompono.Loggingbehavior is unchanged apart from that additive property. Native AOT, ApiCompat, docs, the API reference, and the distributedcomponoskill all reflect the shipped API.
Scope¶
In: everything in ADR-0071's Decision Outcome, plus Workstream B below.
Out (do not build):
- Opt-in forwarding of composed loggers into a
LogCollector(ADR-0071 Axis 2, deferred). NoLoggingOptionschanges. - Any
LogCollectorconstructor options or Compono-side level gate (ADR-0071 Axis 3). - A public
ILoggerProvidertype. Composing, replacing, or wrappingILoggerFactory. - Prefix or wildcard category matching, and
ControlLevel-style toggling. - Any new
PackageReferenceinsrc/Compono.Logging, any change to coreCompono,Compono.Generators, or any othersrc/package. - Metrics (#165), #166, and any unrelated dependency bump (for example
Basic.Reference.Assemblies.Net110stays at1.8.12,Microsoft.NET.Test.Sdkstays at18.10.1, and the Roslyn4.11.0floor stays unchanged). - Package version changes, NuGet publishing, and edits to the v1.4.0 draft release.
- Real-consumer dogfood (product-owner decision, 2026-11-10: skipped. In-repo Generic Host tests, pack-and-consume, and the AOT smoke test replace it).
Release: both workstreams ship in the #161 release (v1.4.0). There is no interim release.
Decisions already made (do not reopen during implementation)¶
From the maintainer interview (2026-11-10), recorded in ADR-0071:
| # | Decision |
|---|---|
| D1 | Explicit, test-owned LogCollector. Attach it with ILoggingBuilder.AddComponoCapture(LogCollector). The provider is internal. |
| D2 | Composed loggers and host capture are separate in v1. |
| D3 | LogVerificationBuilder gains AtLevelOrAbove(LogLevel) + InCategory(string). |
| D4 | CapturedLogEntry.Category. CapturingLogger<T> gets exact parity with LoggerFactory.CreateLogger<T>(). Non-generic gets "". |
| D5 | Type name is LogCollector. The extension class is ComponoLoggingBuilderExtensions. Both live in namespace Compono.Logging. |
| D6 | Filtering is host rules only. LogCollector() is parameterless. The provider has [ProviderAlias("Compono")]. |
| D7 | .NET 11 GA ships as its own PR, first. #164 is built on top of it. |
| D8 | Dogfood is skipped. |
Behavioral requirements that ADR-0071 pins down and tests must prove: registration cardinality (same collector registered twice is idempotent, distinct collectors fan out, one collector across hosts is allowed), the disposal model (the host disposes the provider, and disposal stops subsequent capture through that provider, including through loggers that callers still hold, with no exceptions; entries captured before disposal stay readable through the collector, which is never cleared), the intentional CapturedLogEntry equality change (record equality now includes Category, kept and documented as a behavioral compatibility change), scope sourcing (ISupportExternalScope with a fallback), IsEnabled == level != None, and one shared entry-construction path for the composed and host paths.
Left to engineering judgment: internal type names and file layout, how LogEntryCollector is refactored so both paths share entry construction and a store, how LogVerificationBuilder gets its entry source (for example an internal snapshot delegate or interface), the registration mechanism that meets the cardinality rules, and how category names are computed (as long as parity tests pass).
PRs and passes¶
| PR | Branch (implementer creates from main) | Beads |
|---|---|---|
PR 1: chore: migrate to .NET 11 GA SDK and packages | chore/dotnet-11-ga | B1 |
PR 2: feat(logging): host-wide log capture (#164) | feat/164-host-wide-log-capture, created from main after PR 1 merges | B2–B7 |
Beads (local-only tracker): epic compono-yce. B0 = compono-yce.1, B1 = .2, B2 = .3, B3 = .4, B4 = .5, B5 = .6, B6 = .7, B7 = .8.
PR 2 is one cohesive feature PR. Its passes are internal checkpoints, the same model as PLAN-0071: no docs-less or AOT-unvalidated partial merge. Every pass leaves the branch at dotnet build 0 warnings / 0 errors with the pass's focused tests green. The full suite runs only at B1 and B7 (and wherever a pass says so), not after every small change. Commits, pushes, and PRs need product-owner approval.
B0 (PO accepts ADR-0071/PLAN-0072)
└─ B1 .NET 11 GA (PR 1, merged)
└─ B2 Category + verify filters + shared entry construction
└─ B3 LogCollector + provider + AddComponoCapture (+ core host tests)
├─ B4 Edge-case / concurrency / isolation tests
│ └─ B5 Native AOT + package compatibility
└─ B6 Docs, API reference, skill + evals
(B6 may run in parallel with B4/B5)
B7 Final integration + release-readiness gate (needs B4, B5, B6)
Workstream B: .NET 11 GA (PR 1)¶
Verified versions (authoritative sources, checked 2026-11-10)¶
| Item | Current | Target | Source |
|---|---|---|---|
| .NET SDK | 11.0.100-preview.7.26381.103 | 11.0.100 | builds.dotnet.microsoft.com/dotnet/release-metadata/11.0/releases.json: latest-sdk 11.0.100, latest-runtime 11.0.0, release date 2026-11-10, support-phase active |
Microsoft.Extensions.Logging.Abstractions (net11.0) | [11.0.0-preview.6.26359.118, 12.0.0-a) | [11.0.0, 12.0.0) | nuget.org flat container: 11.0.0 is the newest 11.x |
Microsoft.Extensions.Options (net11.0) | [11.0.0-preview.7.26381.103, 12.0.0-a) | [11.0.0, 12.0.0) | nuget.org: 11.0.0 |
| net8/9/10 ranges | unchanged | unchanged | not part of this migration |
Both 11.0.0 packages ship an empty net11.0 dependency group, so the packed .nuspec for net11.0 still declares no dependency. The existing inspect-packed-nupkgs.sh "absent for net11.0" assertion must keep passing unchanged.
With a stable lower bound, NuGet no longer considers prereleases, so the 12.0.0-a sentinel upper bound and the comment justifying it go away. Use the same [X.0.0, (X+1).0.0) shape as net8/9/10.
B1: .NET 11 GA migration¶
Scope / files:
global.json: changesdk.versionto11.0.100and keeprollForward: latestFeature(the existing policy, unchanged)..github/workflows/package-validation.yaml: change the SDK entry11.0.100-preview.7.26381.103to the floating11.0.x(product-owner decision, 2026-11-11: matchpr-build.yaml/publish-preview.yaml/publish-release.yaml/docs.yml). Retire the exact-pin-in-lockstep-with-global.json policy and rewrite the comment so it no longer describes a preview pin. Keep the RC.1 incident as history..github/workflows/aot-validation.yaml: fix the stale comment saying "global.json pins a preview 11.0.x SDK".11.0.xstays.pr-build.yaml,publish-preview.yaml,publish-release.yaml, anddocs.ymlneed no change, because floating11.0.xnow resolves to GA. Confirm this, don't edit it.Directory.Packages.props: change the two net11.0 ranges above and rewrite their prerelease-lower-bound comments.- Current-state docs: grep
README.md,docs/getting-started,docs/packages,docs/how-to,docs/troubleshooting,docs/contributing.md, andskills/for .NET 11 preview/RC wording, and fix any you find. (A 2026-11-10 sweep found none, so this should be a confirm-only step.) Do not edit ADRs, research docs, or completed plans. Their preview references are historical record.
Acceptance criteria:
git grep -nE "11\.0\.[0-9]+-(preview|rc)"outsidedocs/adr,docs/research,docs/plans, anddocs/reference/apireturns only the intentional RC.1 history line inpackage-validation.yaml's comment.dotnet --versionat the repo root prints11.0.100.dotnet buildis 0 warnings / 0 errors anddotnet testis fully green, across all four TFMs.obj/project.assets.jsonforsrc/Compono.Loggingandsrc/Compono.Optionsresolves11.0.0(not a preview) for net11.0.- Packed
Compono.Logging/Compono.Options.nuspecnet8/9/10 ranges are unchanged, and net11.0 still has no dependency entry (inspect-packed-nupkgs.shandinspect-packed-nupkgs.tests.shpass). - The NUnit compatibility matrix passes on 11.0.100 in both the classic VSTest and MTP legs, and the classic leg genuinely runs the classic host (PR #172's host-identity assertion). Resolved risk: the earlier classic-VSTest "No row-binding dispatch is registered for Widget" failure was a pre-existing Compono.NUnit defect that newer VSTest hosts exposed (not caused) — fixed in PR #172 / ADR-0059 Amendment 1, not an RC.1 or GA regression.
- Package validation (ApiCompat against the nuget.org baselines, all 13 packages) passes with no new suppressions.
- AOT validation passes for every leg on the GA SDK with zero IL/AOT warnings.
- Generator tests are green on the GA SDK's compiler, with
Basic.Reference.Assemblies.Net110left at 1.8.12. If they fail because of the reference-assembly package, stop and ask before bumping it (it's an unrelated dependency).
Validation: V1, V3 (all legs), V6, V7, V8 below.
Deliverable / checkpoint: PR 1 opened (with product-owner approval), CI green, and merged. Record the CI run links and the matrix outcome in this plan's Notes. Tick B1.
- B1.1 SDK pin (
global.json,package-validation.yaml) and comment fixes - B1.2 net11.0 package ranges (
Directory.Packages.props) - B1.3 Current-state docs sweep (confirm or fix)
- B1.4 Full validation (V1, V3, V6, V7, V8) on 11.0.100, results recorded in Notes
- B1.5 PR 1 merged
Workstream A: host-wide log capture (PR 2)¶
B2: Category, verification filters, shared entry construction¶
Scope / files: src/Compono.Logging/CapturedLogEntry.cs, LogEntryCollector.cs (refactor), CapturingLogger.cs/CapturingLogger{T}.cs, LogVerificationBuilder.cs, LoggerTestingExtensions.cs (one sentence added to the not-a-capturing-logger message), test/Compono.Logging.Tests/*.
Work:
- Add
public string Category { get; }toCapturedLogEntryand to its internal constructor. - Refactor so that one internal component builds a
CapturedLogEntryfrom(category, level, eventId, state, exception, formatter, scope snapshot). B3's provider must reuse it. It must stay reflection-free, and the existingExtractStructuredState/scope-snapshot behavior must be unchanged. CapturingLogger<T>: category is computed once at construction, with exact parity toLoggerFactory.CreateLogger<T>(). Non-genericCapturingLoggergets"". No public constructor changes.LogVerificationBuilder.AtLevelOrAboveandInCategory, following ADR-0071's exact semantics and failure-message fragments.LoggerTestingExtensions'InvalidOperationExceptionmessage: append a sentence telling host-created loggers to useLogCollector+AddComponoCapture. Keep the type and the existing text.
Tests (required):
- Category parity theory: for a set of
T,new CapturingLogger<T>()'s capturedCategoryequals the category a realLoggerFactory(test-onlyMicrosoft.Extensions.Loggingreference) passes to a probeILoggerProviderforCreateLogger<T>(). The set must include: a plain namespaced class, a nested class, a nested-in-nested class, a closed generic (Gen<int>), a generic nested in a generic, a framework generic (List<Nested>), a type in the global namespace, built-ins (int,string,object,bool), and an array ifILogger<T[]>is constructible. The expected values come from the real factory at test time and are never hard-coded. - Composed
ILogger<T>viaUseLogging()carries the same parity category. Composed bareILoggerandnew CapturingLogger()give"". AtLevelOrAbove: boundary at each level,Nonenever matches, combined with other filters, and failure-message text.InCategory: exact match, case-sensitivity, a near-miss prefix doesn't match, null throws, and failure-message text.- Equality regression test (required, targeted): two entries that are identical except for
Category(for example fromCapturingLogger<A>andCapturingLogger<B>logging the same call) are not equal underEquals/==. Two entries that are identical includingCategory(same logger, deterministic fields) compare equal on every field the test controls. The test name or comment cites ADR-0071's "intentional behavioral compatibility change" so nobody "fixes" it later. - Regression: every existing
Compono.Logging.Teststest passes unmodified, except tests that assert the exact full text of the not-a-capturing-logger message, if any (update the assertion to the new text only).
Acceptance: build 0/0, dotnet test test/Compono.Logging.Tests green, and test/Compono.Generators.Tests' logging tests green (activation unchanged).
- B2.1
Category+ shared entry construction refactor - B2.2 MEL-parity category naming
- B2.3
AtLevelOrAbove/InCategory - B2.4 Exception-message sentence
- B2.5 Tests above, focused suites green
B3: LogCollector, internal provider, AddComponoCapture¶
Scope / files: new src/Compono.Logging/LogCollector.cs, ComponoLoggingBuilderExtensions.cs, an internal provider + logger type, test/Compono.Logging.Tests (new host-integration test file(s)), and Directory.Packages.props (test-only Microsoft.Extensions.Hosting + Microsoft.Extensions.Logging, per-TFM, see below). test/Compono.Logging.Tests/Compono.Logging.Tests.csproj gets the test-only references.
Work: implement ADR-0071's public API, scopes, filtering, ownership, and registration-cardinality rules exactly. src/Compono.Logging.csproj gains no PackageReference.
Test-only package versions: pin each TFM to the newest stable patch in that major, verified on nuget.org on 2026-11-10. Re-verify when you implement, and don't guess:
| TFM | Microsoft.Extensions.Hosting | Microsoft.Extensions.Logging |
|---|---|---|
| net8.0 | 8.0.1 | 8.0.1 |
| net9.0 | 9.0.23 | 9.0.23 |
| net10.0 | 10.0.15 | 10.0.15 |
| net11.0 | 11.0.0 | 11.0.0 |
Use the existing per-TFM conditional ItemGroup pattern in Directory.Packages.props, with a comment saying the references are test-only and never shipped. Exact versions (not ranges) are fine, since these never reach a .nuspec.
Tests (core, required here):
- A real
Host.CreateApplicationBuilder()+AddComponoCapture(logs). Entries arrive fromILoggerFactory.CreateLogger("Arbitrary.Category"), from a container-resolvedILogger<T>(its category matches the parity rule), and from the host's ownMicrosoft.Hosting.Lifetimeon start/stop. - The headline scenario:
logs.Verify().AtLevelOrAbove(LogLevel.Error).Never()passes on a clean run and fails with a readable message when some never-composed category logs anError. - Every entry field is populated correctly for host entries: level, event id, message, exception, state, properties, message template (both the
LogInformationand[LoggerMessage]styles), scopes, and timestamp. - Scopes: host
BeginScopenesting is outermost to innermost and correct acrossawait. A dictionary scope's structured values are reachable. Sibling-task isolation behaves as ADR-0055 documents. - Filtering: under a default host,
Debug/Traceare not captured. AfterSetMinimumLevel(Trace)they are. A host category filter rule excludes a category. ALogging:Compono:LogLevelconfiguration rule (the alias) targets only the Compono provider.LogLevel.Noneis never captured. services.AddLogging(b => b.AddComponoCapture(logs))without a host works.GetLastCapturedEntry,ClearCapturedEntries, and an empty collector.AddComponoCapturenull-argument guards, and it returns the same builder.
Acceptance: build 0/0, dotnet test test/Compono.Logging.Tests green on all TFMs, and no new PackageReference in the shipped project (git diff src/Compono.Logging/Compono.Logging.csproj shows none).
- B3.1
LogCollector - B3.2 Internal provider/logger (
ISupportExternalScope,[ProviderAlias("Compono")]) - B3.3
AddComponoCapturewith the cardinality rules - B3.4 Test-only package wiring
- B3.5 Core host tests green
B4: edge cases, concurrency, isolation, negative tests¶
Scope / files: test/Compono.Logging.Tests/*. Production code changes only to fix defects these tests reveal (note any in Notes).
Tests (required):
- Cardinality: the same collector twice on one builder gives one copy of each entry. Two collectors on one builder both receive every entry. One collector across two hosts receives both hosts' entries, and their categories are distinguishable.
- Disposal regression test (required, explicit): log N entries through a host, then
host.Dispose(). Then, through a still-held containerILogger<T>and a still-heldCreateLogger("X")logger:IsEnabledreturnsfalsefor every level, logging at every level (includingError/Criticaland a directLog<TState>call) appends nothing and doesn't throw, andBeginScopedoesn't throw. The collector still returns exactly the N pre-disposal entries (GetCapturedEntries,GetLastCapturedEntry, andVerifycounts are unchanged), andClearCapturedEntriesstill works. Also: disposal is idempotent (a double dispose doesn't throw), and the provider is disposed by the host (observable through an internal seam). For one collector registered with two hosts, disposing host A stops A's capture while host B keeps capturing into the same collector. - Isolation: two hosts with two collectors logging concurrently share no entries, and there's no static state (a test runs both in parallel and asserts exact disjoint sets).
- Concurrency: many categories log concurrently while other threads call
GetCapturedEntries/Verify/ClearCapturedEntries. There are no exceptions, no torn entries, and the counts are consistent after the writers join. Bounded: fixed iteration counts and no unbounded writer loop (RESEARCH-0033 / the PR #141 CI-hang lesson). - Composed vs. host separation: a test that uses both
UseLogging()composition and a host with a collector. The composed logger'sVerify()never sees host entries, and the collector never sees composed entries. - Host loggers + extension methods: calling
GetCapturedEntries()/Verify()on a hostILogger<T>throwsInvalidOperationException, and the message mentionsLogCollector. - Activity scopes: with
ActivityTrackingOptionsenabled and anActivitystarted, the activity scope appears inScopesand doesn't break extraction (this documents the ADR consequence). - Exceptions: an entry with an exception and no message args, plus an
AggregateException, are captured by reference.
Acceptance: dotnet test test/Compono.Logging.Tests green; then full dotnet build && dotnet test green (the first full run on PR 2).
- B4.1 Cardinality + disposal
- B4.2 Isolation + bounded concurrency
- B4.3 Separation, host-logger misuse, activity scopes, exceptions
- B4.4 Full suite green
B5: Native AOT + package compatibility¶
Scope / files: test/Compono.Logging.AotSmokeTest/Program.cs + .csproj (test-only Microsoft.Extensions.Logging or Microsoft.Extensions.Hosting reference for net10.0 only, matching the project's TFM), verify-packaging.sh if needed, and .github/scripts/inspect-packed-nupkgs.sh (only if a "no new dependency" assertion is missing for Compono.Logging).
Work / checks:
- The AOT smoke test drives a real
LoggerFactory(or host) withAddComponoCapture, logs across several categories with a scope and a structured message, and asserts category, level, properties, scopes,AtLevelOrAbove, andInCategory. It must also assert theCapturingLogger<T>category parity under AOT. dotnet publish -p:PublishAot=trueproduces zeroIL2xxx/IL3xxxwarnings, and the published binary exits 0.- Pack-and-consume: pack
Compono+Compono.Loggingto a local feed and consume them from a scratch console/test project outside the repo (JIT), using a real Generic Host. Run the headline scenario. - ApiCompat:
dotnet pack src/Compono.Logging -c Release -p:PackageValidationBaselineVersion=<latest stable on nuget.org>reports additions only, with no suppression file changes. .nuspecdependency groups forCompono.Loggingare byte-for-byte the same set as onmain(no new dependency on any TFM).
Acceptance: V3 (Logging leg), V5, V6, V7 pass, with results recorded in Notes.
- B5.1 AOT smoke test extended, publish clean, binary passes
- B5.2 Pack-and-consume
- B5.3 ApiCompat + nuspec dependency check
B6: docs, examples, API reference, compono skill¶
Scope / files:
docs/packages/compono-logging.md: a new "Host-wide capture" section (Generic Host,services.AddLogging,WebApplicationFactory-styleConfigureLogging),Category, the two new filters, separate-destination semantics, host-filtering consequences (Debug/Trace +SetMinimumLevel, theComponoalias), ownership/disposal (disposal stops capture, and pre-disposal entries stay readable), cardinality, and activity scopes. Add a clearly labelled behavioral compatibility note:CapturedLogEntryequality now includesCategory. Draft a matching release-note line in PLAN-0072 Notes for the v1.4.0 release author. Don't edit the draft release itself. Change the "ILoggerFactorycomposition" boundary bullet to say host provider capture is supported, but composing anILoggerFactoryisn't.- Cookbook/how-to: one recipe, "Assert nothing logged at Error+ across a Generic Host" (place it following existing cookbook conventions).
- Troubleshooting: "
Verify()on a hostILogger<T>throws" and "Debug entries are missing fromLogCollector". docs/public-api.md/docs/architecture.md: update only if they enumerate theCompono.Loggingsurface.docs/adr/0071-...: Status staysAccepted(set at B0). Add an amendment only if implementation changes a decision, and stop and ask before doing that.- API reference: regenerate
docs/reference/apibefore push (memory rule: regenerate whenever public XML docs change). skills/compono/references/logging.md: host capture,LogCollector, the new filters,Category, separation, and filtering. Update the "NoILoggerFactorycomposition" boundary line.skills/compono/SKILL.md: update only if its routing or summary mentions logging boundaries.skills/compono/evals/evals.json: add at least two evals. (1) A Generic Host "no Error+ in any category" prompt whose expected answer usesLogCollector+AddComponoCapture+AtLevelOrAbove, notFakeLogCollectoror a composed logger. (2) A "why doeslogger.Verify()throw on my hostILogger<T>" prompt. Follow the existing eval schema (id,category,prompt,expected_output,expectations,files).- Every public member added in B2/B3 has XML docs.
Acceptance: V4 shows no uncommitted API-reference drift after regeneration, V8 passes, every relative link resolves, and the docs describe only shipped behavior.
- B6.1 Package doc + recipe + troubleshooting
- B6.2 API reference regenerated
- B6.3 Skill reference + evals
- B6.4 Link and validation checks
B7: final integration and release-readiness gate¶
Preconditions: B1 merged (PR 2's branch is based on post-B1 main), and B4, B5, and B6 done.
Checks:
- Rebase or confirm on the latest
main.dotnet --versionis11.0.100. - V1 full:
dotnet build0/0 anddotnet testgreen on all TFMs. - V3 AOT for the Logging leg (and any leg whose inputs changed).
- V6 ApiCompat for
Compono.Logging, plus V7 nuspec/package inspection. - V8 skill validation.
- Cross-check every #164 acceptance criterion against a test or doc (table in Notes):
- any-category capture (B3)
Categoryplus every existing field (B2/B3)Verify()vocabulary including the level threshold (B2/B3)- no breaking change for existing capture (B2 regression + B4 separation), with the one intentional equality change pinned by B2's equality regression test and documented in B6
- relationship documented (B6)
- no new dependency (B5)
- AOT/trimming (B5)
git diff main --stattouches onlysrc/Compono.Logging,test/Compono.Logging.Tests,test/Compono.Logging.AotSmokeTest,Directory.Packages.props(test-only entries), docs,docs/reference/api, andskills/. There are no core, generator, or other-package changes.-
Set PLAN-0072 to
Done, tick all tasks, and close the beads. The PR references #164 (it closes #164, not #161). -
B7.1 Full validation green
- B7.2 Acceptance-criteria cross-check table in Notes
- B7.3 Plan closeout, PR 2 opened with product-owner approval (PR #177)
B0: product-owner acceptance (precedes everything)¶
- Product owner reviews ADR-0071, PLAN-0072, and the beads, then flips ADR-0071 to
Accepted(and updates thedocs/adr/README.mdrow and ADR-0055 Amendment 6's "(Proposed)" note). Done 2026-11-10 (maintainer approval; beadcompono-yce.1closed).
Critical Files¶
global.json,Directory.Packages.props,.github/workflows/package-validation.yaml,.github/workflows/aot-validation.yaml(B1)src/Compono.Logging/CapturedLogEntry.cs:Category(B2)src/Compono.Logging/LogEntryCollector.cs: shared entry construction (B2)src/Compono.Logging/CapturingLogger{T}.cs,CapturingLogger.cs: category (B2)src/Compono.Logging/LogVerificationBuilder.cs: two filters, entry source (B2)src/Compono.Logging/LoggerTestingExtensions.cs: message sentence (B2)src/Compono.Logging/LogCollector.cs: new (B3)src/Compono.Logging/ComponoLoggingBuilderExtensions.cs: new (B3)src/Compono.Logging/<internal provider>.cs: new (B3)test/Compono.Logging.Tests/**,test/Compono.Logging.AotSmokeTest/**(B2–B5)docs/packages/compono-logging.md, cookbook/troubleshooting pages,docs/reference/api/**,skills/compono/references/logging.md,skills/compono/evals/evals.json(B6)
Test Plan¶
Per references/testing.md: xUnit v3 on MTP, handwritten explicit test data, and every TFM. B2 covers unit-level behavior: category parity, filters, and the regression run. B3 covers real Generic Host integration. B4 covers edge cases, bounded concurrency, isolation, and disposal. B5 covers Native AOT and packaged consumption. Expected category values always come from a real LoggerFactory at test time.
Validation commands¶
Run from the repo root. $SCRATCH is any directory outside the repo.
# V1 full
dotnet --version # expect 11.0.100
dotnet build && dotnet test
# V2 focused (iteration)
dotnet test test/Compono.Logging.Tests
dotnet test test/Compono.Generators.Tests
# V3 Native AOT, Logging leg (CI: aot-validation.yaml runs every changed leg)
bash test/Compono.Logging.AotSmokeTest/pack-compono.sh
dotnet publish test/Compono.Logging.AotSmokeTest -c Release -f net10.0 -r osx-arm64 \
--self-contained true -p:PublishAot=true \
-o "$SCRATCH/logging-aot" 2>&1 | tee "$SCRATCH/logging-aot.log" # CI uses linux-x64
grep -E "warning (IL|AOT)[0-9]+" "$SCRATCH/logging-aot.log" && echo FAIL || echo OK
"$SCRATCH/logging-aot/Compono.Logging.AotSmokeTest"
bash test/Compono.Logging.AotSmokeTest/verify-packaging.sh
# B1 only: repeat the pack/publish/run for every AotSmokeTest leg.
# V4 API reference
dotnet tool restore && .github/scripts/generate-api-reference.sh
git status --short docs/reference/api
# V5 pack-and-consume: pack Compono + Compono.Logging to a local feed and consume from
# $SCRATCH (Generic Host + AddComponoCapture headline scenario).
# V6 package validation
dotnet pack src/Compono.Logging -c Release -p:PackageValidationBaselineVersion=<latest stable>
# B1: every package in package-validation.yaml's PACKAGES list.
# V7 package inspection
bash .github/scripts/inspect-packed-nupkgs.tests.sh
# plus inspect-packed-nupkgs.sh against freshly packed nupkgs (see package-validation.yaml)
# V8 NUnit compatibility matrix (B1 only; classic VSTest + MTP legs)
.github/scripts/nunit-compatibility-matrix.sh
# V9 skill (B6/B7)
python3 -c "import json;json.load(open('skills/compono/evals/evals.json'))"
# plus quick_validate.py from skill-creator, and every relative ](...) link under skills/compono exists
Notes¶
- 2026-11-10 (design): a spike (class library referencing only
Logging.Abstractions, net8.0 + net11.0,IsAotCompatible, real host on 11.0.100) confirmed every factual premise in ADR-0071's Context: no new dependency, host defaultInformation, external scopes, instance- vs. factory-registered disposal, theProviderAliaslocation, andLoggerFactorycategory naming (N.Outer+Inner→N.Outer.Inner,Gen<int>→Gen,int→int). - 2026-11-10 (ADR review round 1, product owner): architecture and plan approved, subject to two clarifications, both now applied. (1) Disposing the provider stops subsequent capture through it, while pre-disposal entries stay readable. B4 has an explicit regression test for this. (2)
CapturedLogEntryequality includingCategoryis kept as an intentional behavioral compatibility change, with a B2 regression test and B6 docs and release-note text. Duplicate-registration semantics are unchanged and not expanded. - 2026-11-10 (final planning approval): the maintainer approved ADR-0071 and PLAN-0072, including
768af68. ADR-0071 →Accepted, its index row and ADR-0055 Amendment 6 were updated, and B0 is closed. Plan status staysNot Starteduntil B1 begins. - 2026-11-11 (B1 resume): resolved the planning-branch integration — the accepted ADR-0071/PLAN-0072/ADR-0055-Amendment-6 artifacts were merged to
mainvia PR #175 (merge commit 4f56a03). Reconstructedchore/dotnet-11-gafromorigin/main(4f56a03) carrying only the four intended B1 edits, so the B1 diff is B1-only (no planning or unrelated doc commits). Reconciled the CI SDK policy to the approved form:global.jsonpins11.0.100withrollForward: latestFeature;package-validation.yamlfloats11.0.x. - 2026-11-11 (risk resolution): the RC.1-era classic-VSTest NUnit failure ("No row-binding dispatch is registered for Widget") was NOT an SDK regression — it was a pre-existing Compono.NUnit discovery-time defect, fixed in PR #172 (ADR-0059 Amendment 1); PLAN-0073 is Done. The matrix's classic leg now also asserts the real classic host (PR #172's host-identity check), so V8 is a genuine classic-host gate.
- 2026-11-11 (B1.4 full validation on the GA SDK 11.0.100 — all green):
- V1 build
dotnet build Compono.slnx -c Release: 0 warnings, 0 errors. - V1 test
dotnet test Compono.slnx -c Release --no-build: 6791 total, 0 failed, 6290 succeeded, 501 skipped. - V8 matrix
.github/scripts/nunit-compatibility-matrix.sh(exit 0): NUnit 3.14.0 and 4.6.1 each under genuine classic VSTest (VSTest 18.12.0) and MTP at 5/5 per leg — including the host-identity assertion — plus 5.0.0-beta.1 surveillance 5/5 under both hosts. - V3 AOT: all nine
test/*AotSmokeTestlegs pack + publish + run PASS (net10.0). Only pre-existing third-party trim/AOT warnings surfaced (nunit.frameworkIL3053/IL2104,xunit.v3.coreIL2104, Compono.TUnit harness's own IL2072); none attributable to Compono. B1's diff touches no net10.0-affecting source (theDirectory.Packages.propsedits are strictlynet11.0-conditional), so every AOT leg is byte-identical tomain. - V6 ApiCompat: all 13 publishable packages pack against the
1.4.0-preview.123baseline with 0CPerrors. - V7 packed-nupkg inspection:
inspect-packed-nupkgs.tests.shandinspect-packed-nupkgs.shboth pass (net11.0 stays dependency-free for the framework-providedMicrosoft.Extensions.*packages). - API-reference drift:
generate-api-reference.sh+git diff --exit-code -- docs/reference/apiclean. -
Preview/RC grep: the only tracked match outside
docs/adr,docs/research,docs/plans,docs/reference/apiis the intentional RC.1 history line inpackage-validation.yaml. -
2026-11-11 (B2-B6 implementation, branch
feat/164-host-wide-log-capture): - Source:
CapturedLogEntry.Category; one shared entry-construction path (LogEntryFactory) used by bothLogEntryCollectorand the host provider;LoggerCategoryNamereproduces MEL'sTypeNameHelper.GetTypeDisplayNameresult without reflection beyond trim-safeTypename properties;LogCollector+ComponoLoggingBuilderExtensions.AddComponoCapture; internalComponoLogCollectorProvider/ComponoLogCollectorLoggerwithISupportExternalScope;AtLevelOrAbove+InCategoryonLogVerificationBuildervia the internalILogEntrySourceseam. Registration is idempotent per collector instance withoutTryAddEnumerable, so two distinct collectors on one builder each get a provider. ProviderAliasAttributeavailability correction: it is inLogging.Abstractionsonly for net10.0/net11.0, not net8.0/net9.0. The attribute is compiled under#if NET10_0_OR_GREATER, the shipped dependency graph is unchanged, and the alias config rule is tested only on net10/net11. Recorded as ADR-0071 Amendment 1 (2026-11-11).- Tests:
test/Compono.Logging.Tests514 passed / 0 failed across net8.0/net9.0/net10.0/net11.0. New coverage: MEL category parity against a realLoggerFactoryprobe (including nested, closed and multi-argument generics, global-namespace, built-in and array categories), equality regression forCategory, both new filters and their failure text, the provider's fallback scope source, real Generic Host capture (arbitrary/container categories, host lifetime, every entry field,[LoggerMessage], scopes acrossawait, sibling-task isolation, activity scopes), host filtering (default Information minimum,SetMinimumLevel,AddFilter, net10+ alias), cardinality, disposal (post-dispose loggers disabled, entries remain readable,Clearstill works, double dispose safe, two-host independence), isolation, and bounded concurrency (including multi-category writes racing reads and clears). - Validation (B7):
dotnet build Compono.slnx -c Release0 warnings / 0 errors;dotnet test Compono.slnx -c Release --no-build7057 total, 0 failed, 6556 succeeded, 501 skipped. - AOT (V3 logging leg): pack-and-publish
net10.0/osx-arm64PublishAot=truebinary passed, host-wide capture and category parity assertions included, and the publish emitted zero IL2xxx/ IL3xxx warnings.verify-packaging.shpassed. - ApiCompat (V6): packed
Compono.Loggingagainst1.4.0-preview.125(current nuget.org latest) with noCPerrors; only additions. Packed nuspec dependency groups are identical to that baseline package's groups (V7), andinspect-packed-nupkgs.tests.shpassed. - API reference regenerated with
.github/scripts/generate-api-reference.sh; post-generation status contains only the intended new/changedCompono.Loggingpages. Skill validation:evals.jsonparses (62 evals) and skill-creatorquick_validate.pyreports "Skill is valid!". - Docs:
docs/packages/compono-logging.mdhost-wide section (Category, filters, host filtering, separate destinations, ownership/cardinality, activity scopes, intentional equality change);docs/how-to/assert-nothing-logged-at-error-across-a-host.md(nav + index + documentation architecture); troubleshooting entries for hostILogger<T>Verify()and missing Debug entries;skills/compono/references/logging.md+SKILL.md; two new evals. - PR 2: #177 (
feat/164-host-wide-log-capture). Release-note line for the v1.4.0 draft author (not edited here): "Compono.Logging:CapturedLogEntryequality now includes the newCategoryproperty. This is a behavioral change to value equality, not a source or binary breaking change." -
Acceptance criteria cross-check for #164:
# Criterion Evidence 1 Host-created loggers of any category captured HostLogCaptureTestsarbitrary/container/lifetime categories; AOT host smoke2 Category + existing fields exposed CapturedLogEntry.Category;HostLogCaptureTestsfield assertions3 Existing Verify()vocabulary + level thresholdAtLevelOrAbove/InCategorytests; headline Generic Host test4 Composed capture unchanged Existing suite unchanged; category parity and separation tests 5 Host/composed relationship documented ADR-0071; package doc "Host vs. composed"; logging skill 6 No new package dependency Compono.Logging.csprojunchanged; packed dependency groups identical7 Native AOT/trimming retained IsAotCompatibleunchanged; AOT smoke publish zero IL warnings- 2026-11-11 (independent review follow-up, PR #177): addressed review findings by fixing the test csproj comment placement and stale amendment count, marking the provider scope field volatile, documenting thedeliberate !uses inLoggerCategoryName, renaming the alias test todescribe what it proves, and adding tests for the provider fallback scope source, multi-argument generic and nested global-namespace categories, InCategory("")on a bare logger, and multi-category writes racing readsand clears. Re-ran focused logging tests (514 passed/0 failed per TFM), full build 0/0, full suite 7057 total/0 failed, and the AOT logging leg (PASS, zero IL warnings). -
2026-11-11 (independent review remediation, PR #177): a second review round raised seven findings; disposition below. Nothing here is merged yet — PR #177 is still open for maintainer review.
-
F1 — confirmed registration defect (fixed). Registering a collector, calling
ClearProviders(), then registering the same collector silently stopped capturing: the old per-collector guard relied on a separate registration-marker descriptor thatClearProviders()does not remove, so the re-registration was treated as a duplicate while its provider was gone. Fix:ComponoCaptureRegistrationis no longer a standalone marker service; it now exposes an instance method whose delegate target is the live registration, andIsAlreadyRegisteredscans the currentILoggerProviderdescriptors for that factory target (identity by collector reference). Regression tests added totest/Compono.Logging.Tests/LogCollectorRegistrationTests.cs:ClearProvidersThenReRegisteringTheSameCollector_StillCaptures,ClearProvidersThenReRegisteringTwice_StaysIdempotent,ClearProvidersThenRegisteringADifferentCollector_OnlyTheNewCollectorCaptures,SameCollectorOnIndependentServiceCollections_IsNotTreatedAsADuplicate. - F2 — provider-alias availability (fixed, ADR-0071 Amendment 2). The
Componoprovider alias was unavailable on net8.0/net9.0 becauseProviderAliasAttributeonly exists inLogging.Abstractionsfor net10.0+. The attribute is now applied unconditionally and an internal same-full-name copy (src/Compono.Logging/ProviderAliasAttribute.cs, under#if !NET10_0_OR_GREATER) is compiled for the older TFMs; MEL resolves the alias by full type name, so the copy is honored. No new package dependencies;CS0436is promoted to an error inCompono.Logging.csprojso a consumer-visible type collision cannot slip through. Recorded as ADR-0071 Amendment 2 (2026-11-11); Amendment 1's historical text is unchanged. - F3/F4/F5/F6 — documentation corrections.
InCategoryis exact ordinal full-type-name matching, not prefix matching likeAddFilter(so.InCategory("Payments")matches nothing in a real host; use.Matching(e => e.Category.StartsWith(...))for namespaces).Never()'s failure message names the filters and match count but does not include the matching entries. A sharedWebApplicationFactorycollector retains entries across tests until explicitly cleared. A third-partyILoggerFactoryreplacement (or a post-registrationClearProviders()) can bypass the registeredILoggerProviders and yield an empty capture. Updateddocs/packages/compono-logging.md,docs/how-to/assert-nothing-logged-at-error-across-a-host.md,docs/troubleshooting/common-errors.md+index.md,skills/compono/references/logging.md, and evals #63/#64. - F7 — deferred. AOT warnings are not universally promoted to errors in this repo (a pre-existing repo-wide policy gap, not specific to this PR). Left out of scope here.
-
Validation:
dotnet build Compono.slnx -c Release0 warnings/0 errors; full suite green (7087 total, 0 failed, 6586 succeeded, 501 skipped) with the focused logging suite at 544/0. ApiCompat (V6):Compono.Loggingpacks against the1.4.0-preview.125baseline with noCPerrors (additions only). V7 packed-nupkg inspection passes and the packed dependency groups are unchanged. Pack-and-consume (F2): a multi-target (net8.0;net9.0;net10.0;net11.0) consumer restored from a local feed packed from this worktree (sha512 verified) builds 0/0 withCS0436/CS0104as errors and runs on every TFM —Logging:Compono:LogLevel:Defaultfilters the Compono provider, an unrelated provider is unaffected, and a consumer-definedProviderAliasAttributeuse compiles unambiguously. AOT for the net8.0/net9.0 legs (not covered by CI's net10.0-only publish) was verified locally:PublishAotosx-arm64 binaries built with zero IL2xxx/IL3xxx warnings and ran, honoring the alias. -
2026-11-11 (review round 2 corrections, PR #177): Claude's second read-only review approved with two non-blocking follow-ups, both addressed. Still open for maintainer review; not merged.
-
Capture sanity-check docs:
logs.Verify().InCategory("Microsoft.Hosting.Lifetime").AtLeast(1)is only valid for a Generic Host running its default lifetime. Under aWebApplicationFactory/ASP.NET CoreTestHostit is unreliable (different host lifetime, and a shared fixture may clear startup entries between tests), so it can fail even when capture is wired correctly. The recommendation is now host-aware: keep theMicrosoft.Hosting.Lifetimeassertion for a Generic Host, but forWebApplicationFactoryassert a deterministic application-owned entry emitted during the operation under test. Updated the how-to, package guide, troubleshooting entry, and skill logging reference; sharpened eval #63 and added eval #65. - Keyed-provider regression: added
KeyedProviderDescriptorPresent_DoesNotBreakRegistrationOrCapture(a keyedILoggerProviderplusAddComponoCapturemust still capture and must not throw) and verified it on net8.0/net9.0/net10.0/net11.0. While proving it, found theIsAlreadyRegisteredcomment was wrong: on supported runtimes a keyed descriptor'sImplementationFactoryisnull, not throwing (it isKeyedImplementationFactorythat throws on a non-keyed descriptor), so the!IsKeyedServiceguard is defense in depth rather than a throw guard. The guard stays; its comment and the test comment were corrected to match. - Validation:
dotnet build Compono.slnx -c Release0/0;test/Compono.Logging.Tests548 passed/0 failed (137 per TFM); full suite 7091 total/0 failed/6590 succeeded/501 skipped;mkdocs build --clean --strictexit 0; API reference no drift;evals.jsonparses (65 evals) andquick_validate.pyreports valid. No package/AOT-affecting change, so those gates were not re-run.