[PLAN-0074] Compono.Metrics: System.Diagnostics.Metrics Testing Support (Issue #165)¶
Status: In Progress
Implements: ADR-0072 (issue #165, child of #161). ADR-0072 is Accepted (product-owner final review, 2026-11-11), and so is this plan. The design is approved. Implementation phases B1–B6 are complete and independently reviewed with follow-up corrections applied (see ## Notes); the change is uncommitted on docs/165-metrics-planning, awaiting the single combined PR.
Goal¶
A test can write builder.UseMetrics(), take [Shared] IMeterFactory meters next to a SUT that records metrics, dispose it with using var _ = meters;, and assert meters.Verify<long>("shop.orders.placed").WithTag("region", "eu").Once(). Measurements are isolated per factory, observable instruments poll only on RecordObservableInstruments(), and the package has no dependency beyond core Compono. It builds and tests on net8.0–net11.0, packs, package-validates, and passes the CI Native AOT smoke test (net10.0, linux-x64). It ships with a package guide, a host recipe, one BasicUsage sample, the API reference, and compono skill coverage.
Scope¶
In: everything in ADR-0072's Decision Outcome.
Out (do not build): everything in ADR-0072's "Explicit non-goals", plus:
- Any change to core
Compono,Compono.Generators, or any othersrc/package. If implementation finds that core needs a change, stop and raise it. It's a design question, not an implementation detail. - Any
PackageReferenceinsrc/Compono.Metrics. - Any ownership/disposal API (ADR-0072 "Disposal patterns" is documentation only).
- A new public exception type (conflicts use
InvalidOperationException). - Extending the CI AOT smoke matrix beyond its existing net10.0/linux-x64 shape.
- Package version bumps and NuGet publishing (release-owner action).
Release: ships in the #161 release, v1.4.0, alongside #164.
Delivery (D8, revised): the maintainer decided the feature ships in one combined PR. The planning artifacts (RESEARCH-0039, ADR-0072 Accepted, this plan) travel on the same branch as the implementation (B1–B6); there is no separate planning-only PR and no planning-PR merge gate. The combined PR also carries the docs/plans/README.md and docs/adr/README.md rows.
Dogfood: optional per repo policy (2026-11-04). Not planned. In-repo pack-and-consume and the AOT smoke test cover the integration risk.
Decisions already made (do not reopen during implementation)¶
From the maintainer interview and review (2026-11-11), recorded in ADR-0072:
| # | Decision |
|---|---|
| D1 | New package Compono.Metrics, no external dependency. |
| D2 | The composed CapturingMeterFactory is the handle ([Shared] IMeterFactory, extension methods). |
| D3 | One lazy MeterListener per factory, scope-filtered, disposed with the factory. No static state. |
| D4 | Typed reads: CapturedMeasurement<T> (plain readonly struct), GetMeasurements<T>, Verify<T>. |
| D5 | Selection follows ADR-0072's seven ordered rules: unsupported T → ArgumentException; unknown name → empty; ambiguous meter names → throw; mixed value types → type-conflict throw, even if one is T; single other type → mismatch throw; otherwise merge. |
| D6 | Observable instruments poll only on explicit RecordObservableInstruments(). |
| D7 | Verify<T> filters (WithValue, WithTag, WithoutTag, Matching) + CallVerifier counts. No aggregate helpers. |
| D8 | Docs PR first, then one implementation PR. |
| D9 | Host/DI path is a documentation recipe only. |
| D10 | Meter parameters aren't composed (documented recipe). |
| D11 | No wait API. No timestamp. Tag match is subset + object.Equals, no coercion. |
| D12 | Precedence is registration order (ADR-0024), documented. No generator exclusion. |
| D13 | Names are ordinal, case-sensitive. Type names: CapturingMeterFactory, CapturedMeasurement<T>, MeterFactoryTestingExtensions, MeasurementVerificationBuilder<T>. |
| D14 | Disposal is test-owned, through the three documented patterns. No ownership API. |
| D15 | Capture order is insertion order into the synchronized buffer. The sequence number is assigned under the append's lock. No cross-producer chronology. |
| D16 | Snapshots hold the real Instrument/Meter references and stay valid after disposal. |
| D17 | Release v1.4.0. One focused BasicUsage sample. |
| D18 | An unsupported T throws ArgumentException. The message names the requested type and lists the seven supported types. |
| D19 | The v1 limitation is accepted: same-named instruments with conflicting value types in same-named meters can't be read. No version or tag selectors. |
| D20 | InvalidOperationException for ambiguity, type conflicts, type mismatch, and misuse. No custom exception hierarchy. |
| D21 | Native AOT matrix as documented: analyzers on net8.0–net11.0, CI publish-and-run on net10.0 linux-x64 only. |
Phases¶
B0 — Acceptance (Done: design accepted)¶
- Product-owner review of RESEARCH-0039, ADR-0072, and this plan. Refinements applied (2026-11-11).
- Final review of the refinements. ADR-0072
Status: Accepted, and itsdocs/adr/README.mdrow updated (2026-11-11). - Delivery revised (2026-11-11): the planning artifacts (RESEARCH-0039, ADR-0072, PLAN-0074, both index rows, and the stale PLAN-0072 row in
docs/plans/README.mdcorrected toDone) land in the single combined PR with the implementation, not a separate planning-only PR. The obsolete planning-PR merge gate is removed.
B1 — Package skeleton and factory (Done)¶
-
src/Compono.Metrics/Compono.Metrics.csproj: TFMsnet8.0;net9.0;net10.0;net11.0,IsAotCompatible=true,ProjectReferencetoComponowith the exact-version pin target (copyCompono.Logging.csproj's),InternalsVisibleTothe test project. NoPackageReference. - Add it to
Compono.slnx. -
CapturingMeterFactory:DefaultMeterFactoryparity (null, foreign-scope, and disposed checks; options copy; meter cache by name- version + order-insensitive tags), lazy listener, idempotent thread-safe
Dispose(meters, then listener).
- version + order-insensitive tags), lazy listener, idempotent thread-safe
-
MetricsProvider(internal, stage 6,typeof(IMeterFactory)only) andUseMetrics().
B2 — Capture, selection, verification (Done)¶
- Typed per-instrument buffers as listener state. Seven numeric callbacks. Tag copy before taking the lock. One lock per factory. The factory-wide sequence number is assigned in the same lock acquisition as the append (D15).
-
CapturedMeasurement<T>as a plainreadonly struct. - Selection exactly per ADR-0072's seven rules, in order (D5). The candidate set is never pre-filtered by
T. -
MeterFactoryTestingExtensions:GetMeasurements(new list per call),Verify(resolves the selection when the builder is created),RecordObservableInstruments,ClearMeasurements, misuseInvalidOperationException(namesUseMetrics(), order, host recipe). -
MeasurementVerificationBuilder<T>withLogVerificationBuilder-style failure messages, including the existing-instruments list on a failed count.
B3 — Tests (Done)¶
See Test Plan. Bounded concurrency only (RESEARCH-0033). test/Compono.Metrics.Tests passes on all four TFMs: net8.0 (77), net9.0 (78), net10.0 (78), net11.0 (78).
B4 — Native AOT and packaging (Done)¶
-
test/Compono.Metrics.AotSmokeTest, targetingnet10.0only, like every existing smoke test (copy theCompono.Logginglayout:pack-compono.sh,nuget.config,verify-packaging.sh). Exercise composition, every instrument kind available on net10.0, observable polling, tags, a type-conflict diagnostic, andVerify. - Wire
Compono.Metricsinto.github/workflows/aot-validation.yaml(theall_legslist and thesrc/Compono\.Metrics/change filter),package-validation.yaml,docs.yml,.github/scripts/inspect-packed-nupkgs.sh(+ its.tests.sh),generate-api-reference.sh, andscripts/dogfood-validate.sh's package list, following each file'sCompono.Loggingentry. - Package validation for a new package (no ApiCompat baseline yet). Follow how the most recent new package was first validated.
- The packed
.nuspechas exactly one dependency (Compono, exact version) per TFM group. - Optional, not a gate: a manual Native AOT publish of a consumer on net8.0/net9.0/net11.0. If done, record it in Notes as evidence and don't present it as CI coverage.
B5 — Documentation, sample, and skill (Done)¶
-
docs/packages/compono-metrics.mdpackage guide: setup,[Shared], disposal patterns (all three, with the "dispose after the SUT's work is done" caveat and the undisposed-leak consequence), instrument kinds, the selection rules (including type conflicts), tag matching, observables, capture order (no cross-producer chronology), snapshot lifetime, precedence, theMeterparameter recipe, the Native AOT validation matrix as stated in ADR-0072, non-goals, and migration fromMetricCollector<T>. -
docs/how-to/recipe: assert metrics from a Generic Host (AddSingleton<IMeterFactory>(meters), test-owned disposal after the host stops). -
docs/troubleshooting/common-errors.md: misuse, meter ambiguity, type conflict, type mismatch, and unsupported-Tmessages. -
docs/packages/index.md,docs/index.md,README.mdpackage lists,mkdocs.ymlnav,docs/architecture/current/provider-pipeline.md(stage-6 provider list). - One focused sample in
samples/Compono.Samples.BasicUsage: aMetricsSampleProfile(UseMetrics()), a small SUT that takesIMeterFactory, and one[Compose<MetricsSampleProfile>]test using[Shared] IMeterFactory,using var _ = meters;, andVerify<T>. Add the package reference the way the sample referencesCompono.Logging. Updatedocs/samples/basic-usage.md. - Regenerate
docs/reference/apibefore pushing (memory: API reference drift). -
skills/compono/SKILL.mddetection table +skills/compono/references/metrics.md(must show the disposal pattern) + evals inskills/compono/evals/evals.json.
B6 — Validation gate and closeout (Done)¶
-
dotnet build0 warnings / 0 errors anddotnet testgreen on all four TFMs. - Pack-and-consume check from a throwaway console project on a local feed.
- CI AOT smoke leg for
Compono.Metricsgreen (net10.0, linux-x64, zero IL2xxx/IL3xxx). - Plan tasks checked and validation recorded. The combined PR (D8, revised) carries this plan and both index rows; the plan stays
In Progressuntil that PR merges, then its index row moves toDone, #165 closes, and #161 is updated.
Critical Files¶
New:
src/Compono.Metrics/—Compono.Metrics.csproj,CompositionBuilderExtensions.cs,MetricsProvider.cs,CapturingMeterFactory.cs,CapturedMeasurement{T}.cs,MeterFactoryTestingExtensions.cs,MeasurementVerificationBuilder{T}.cs, internal capture buffer and selection types.test/Compono.Metrics.Tests/,test/Compono.Metrics.AotSmokeTest/.docs/packages/compono-metrics.md, adocs/how-to/host recipe,skills/compono/references/metrics.md,docs/reference/api/Compono.Metrics/.samples/Compono.Samples.BasicUsage/—MetricsSampleProfile.cs,MetricsTests.cs, a small SUT type (or an addition toDomain.cs).
Modified:
Compono.slnx,.github/workflows/{aot-validation.yaml,package-validation.yaml,docs.yml},.github/scripts/{inspect-packed-nupkgs.sh,inspect-packed-nupkgs.tests.sh,generate-api-reference.sh},scripts/dogfood-validate.sh,mkdocs.yml,README.md,docs/index.md,docs/packages/index.md,docs/samples/basic-usage.md,docs/architecture/current/provider-pipeline.md,docs/troubleshooting/common-errors.md,skills/compono/SKILL.md,skills/compono/evals/evals.json,samples/Compono.Samples.BasicUsage/Compono.Samples.BasicUsage.csproj,Directory.Packages.props(only if the sample's package-reference pattern needs a version entry),.gitignore(if the AOT smoke test needs the same entries as Logging's).- Both index rows (
docs/adr/README.mdanddocs/plans/README.md) travel in the single combined PR (D8, revised); there is no separate planning-only PR.
Not modified: src/Compono, src/Compono.Generators, and every other src/ package.
Test Plan¶
test/Compono.Metrics.Tests (xUnit v3 / MTP, all four TFMs). Each row is an acceptance criterion for B3.
| Area | Cases |
|---|---|
| Factory parity | null options; foreign scope throws; caller options not mutated; same name/version/tags cached (tag order irrelevant); different version → new meter; Create after dispose throws ObjectDisposedException. |
| Composition | UseMetrics() composes IMeterFactory as CapturingMeterFactory; [Shared] gives SUT and test one instance; without [Shared] they differ; provider ignores other types; an exact Register<IMeterFactory> wins over UseMetrics(); order vs UseNSubstitute() (wrong order → misuse exception naming the cause). |
| Instrument kinds | Counter, UpDownCounter, Histogram on all TFMs; Gauge<T> under #if NET9_0_OR_GREATER; each of the seven value types. |
| Observables | nothing recorded before poll; one poll records once; two polls → two measurements; reads don't poll; only this factory's callbacks run (two factories); throwing callback → AggregateException and the other callbacks are still recorded; no-op before the first Create and after dispose. |
| Selection (rules 1–7, one test per rule) | unsupported T (e.g. Guid) → ArgumentException even with no instruments, and the message lists the seven supported types; null/empty names → ArgumentException; unknown name → empty, Never() passes, and a failed Once() lists existing instruments; different meter names with the one-argument overload → ambiguity throw, two-argument overload resolves it; mixed types in one meter (Counter<int>("x") + Counter<long>("x")) → type-conflict throw for <int>, <long>, and <double>, the message lists both instruments, and nothing is silently ignored; mixed types across same-named meters with different versions → type-conflict throw; single other type → mismatch throw naming it; same-named meters with different versions and the same T → merged in capture order; Verify<T> throws the conflict when the builder is created. |
| Matching | WithValue exact (incl. float); WithTag subset; int vs long doesn't match and the message shows the types; WithoutTag; chained filters; Matching; every CallVerifier terminal. |
| Tags | no-tag measurement → empty list (not null); tag order preserved; meter/instrument-level tags not merged; a returned Tags list isn't affected by later measurements. |
| Snapshot lifetime | a list returned before ClearMeasurements()/Dispose()/later captures is unchanged afterwards; after dispose Value, Tags, Instrument.Name, Instrument.Meter.Name/Version are readable and Instrument.Enabled == false; Instrument is reference-equal to the SUT's instrument. |
| Capture order | single-thread recordings across several instruments come back in program order (including a merged selection); one observable poll appends in callback order; under concurrency, sequence numbers are strictly increasing and gap-free per snapshot, with no assertion of cross-thread chronology. |
| Disposal patterns | pattern 1: a real [Compose]-style test body with using var _ = meters; disposes the shared factory on success and when an assertion throws (meters' instruments report Enabled == false afterwards); pattern 2: Composer.Create + Register<IMeterFactory>(() => meters) captures and disposes; dispose idempotent and concurrent-safe; data readable after dispose; ClearMeasurements before/after dispose; recording after dispose not captured. |
| Isolation | two factories, identical meter and instrument names, running in parallel: no cross-contamination. |
| Concurrency | bounded multi-thread recording on several instruments alongside reads and clears; no lost or torn measurements (count invariant); bounded writers per RESEARCH-0033. |
| Host recipe (pattern 3) | real Generic Host (test-only Microsoft.Extensions.Hosting): AddSingleton<IMeterFactory>(meters) before and after AddMetrics() → a SUT resolved from the host records into meters; the container doesn't dispose the instance; the test disposes it after the host stops. |
| Misuse | every extension method on a non-Compono IMeterFactory (hand-written stub) throws InvalidOperationException. |
Plus the BasicUsage sample test (runs in the normal test run), the Compono.Metrics.AotSmokeTest CI leg, and the pack-and-consume check (B6).
Validation matrix (the same as ADR-0072's; only CI-backed cells are claimed):
| Coverage | net8.0 | net9.0 | net10.0 | net11.0 |
|---|---|---|---|---|
Library build + trim/AOT analyzers (IsAotCompatible), warnings as errors | CI | CI | CI | CI |
Compono.Metrics.Tests (JIT) | CI | CI | CI | CI |
Gauge<T> (sync) tests | n/a (BCL) | CI | CI | CI |
Native AOT publish + run (Compono.Metrics.AotSmokeTest, linux-x64) | not covered | not covered | CI | not covered |
Test-framework neutrality comes from core composition. No framework adapter changes. Disposal pattern 1 is framework-independent C#.
Notes¶
- 2026-11-11: Planning drafted from the maintainer interview. BCL facts are in RESEARCH-0039 §5 (spike on SDK 11.0.100).
- 2026-11-11: Product-owner review. Disposal patterns, the type-conflict rule, snapshot lifetime, the capture-order definition, and the explicit AOT matrix were added. Open items resolved (D4, D13, D17; the PLAN-0072 index fix is part of the same combined PR). One addition made while specifying selection: an unsupported
TthrowsArgumentException(rule 1), so the conflict and mismatch rules only deal with real measurement types. - 2026-11-11: Final review. ADR-0072 and this plan accepted. D18–D21 confirmed (unsupported-
Tdiagnostic, the accepted conflicting-type limitation,InvalidOperationExceptiononly, the AOT matrix). Implementation is not started. - 2026-11-11: Implementation (B1–B6) complete on
docs/165-metrics-planning. Evidence: src/Compono.Metricsbuilds with 0 warnings / 0 errors on net8.0–net11.0 with the trim/AOT analyzers.dotnet build Compono.slnx -c Debug→ Build succeeded, 0 Warning(s), 0 Error(s).dotnet test Compono.slnx -c Debug→ total 7403, failed 0, succeeded 6902, skipped 501 (pre-existing platform/placeholder skips).Compono.Metrics.Testsper TFM: net8.0 77, net9.0 78, net10.0 78, net11.0 78 (net8.0 has noGauge<T>, which isnet9.0+BCL).samples/Compono.Samples.BasicUsage(incl. the newMetricsTests) → 8 tests, all passing.- Packaged all 14 publishable packages at 1.0.0 into a temp folder and ran
.github/scripts/inspect-packed-nupkgs.tests.sh(all regression tests passed) and.github/scripts/inspect-packed-nupkgs.sh <folder>(all package-contents assertions passed).Compono.Metrics-specific checks confirmed: the expected file shape,<title>Compono — System.Diagnostics.Metrics Testing Support, nobuild/oranalyzers/, andComponopinned at the exact version per TFM group. - Native AOT:
dotnet publish test/Compono.Metrics.AotSmokeTest -c Release -f net10.0 -r osx-arm64 --self-contained -p:PublishAot=trueproduced zero IL2xxx/IL3xxx warnings, and the published binary ran toPASS(exit 0). CI publishes the same project for linux-x64 only (ADR-0072's matrix; not extended). scripts/dogfood-validate.shdeliberately needed no change: its default set is the original four packages andCompono.Loggingis likewise absent; external dogfood is optional per repo policy.- The implementation is left uncommitted on
docs/165-metrics-planningfor independent review; no PR, push, merge, package publish, release-draft edit, or issue close was performed. The feature ships in the single combined PR (D8, revised); the plan staysIn Progressuntil that PR merges. - 2026-11-11: Independent review (Claude) approved the architecture and public API and raised five follow-up corrections; all are applied and validated. Delivery changed to one combined PR (D8, revised) — the planning artifacts, implementation, docs, and both index rows travel on
docs/165-metrics-planning; there is no separate planning-only PR and no planning-PR merge gate. Corrections: - Gauge capture is TFM-independent.
CaptureState.IsCapturedKind<T>now classifies withinstrument is Instrument<T> or ObservableInstrument<T>(both base types exist on every target), replacing the concrete-kind list with its#if NET9_0_OR_GREATERGauge<T>branch. A net8.0 consumer whose runtime provides DiagnosticSource 9.x now records and verifiesGauge<T>. NewGaugeCaptureTests(3 tests) prove it on all four TFMs; the net8.0 test assembly referencesSystem.Diagnostics.DiagnosticSource9.0.0 test-only (never a productionPackageReference, per ADR-0072). - Meter-tag identity is a one-to-one bag match.
TagSetsEqualnow requires each left entry to consume a distinct right entry, so duplicate keys are significant ([k=1, k=1]≠[k=1, j=2]). This is intentionally stricter thanDefaultMeterFactory, whose duplicate-tag comparison is not a true multiset comparison; the empty-tag case still allocates nothing. ThreeCapturingMeterFactoryTestsregression tests cover it (Create_ReturnsSameInstance_ForDuplicateKeysWithIdenticalValues,Create_ReturnsDifferentInstance_ForDuplicateKeysWithDifferentSecondEntry,Create_ReturnsDifferentInstance_ForDuplicateKeysWithDifferentCount). - Host DI rule corrected. A host's
HostApplicationBuilderalready registers a defaultIMeterFactory; a test factory wins single-service resolution by being the lastAddSingleton<IMeterFactory>registration (neverTryAddSingleton, which keeps the existing one). The extension's misuse message and the package guide, host recipe, and troubleshooting docs were corrected. ThreeHostRecipeTestsprove the default is present, the later registration wins, andTryAddSingletondoesn't replace. - Verification diagnostics. Filters join with
with/and; numeric values format with the invariant culture; aWithTagfilter describes the expected value the test passed - a string stays quoted with no annotation, and every other non-null value shows its runtime type (e.g.status = 200 (int)), so the message never implies it reports the captured measurement's actual tag type; the unknown-instrument suggestion is appended after the coreCallVerifiercount sentence (and names the meter when the two-argument overload was used).CallVerifieritself is unchanged, and no public API changed. - Regression coverage added, each test identified by file and method: interleaved/merged capture ordering -
SnapshotAndDisposalTests.CaptureOrder_ForMergedSelection_OrdersByCaptureSequence_NotByMeterRegistration(fails if the capture-sequence sort is dropped); observable callback that throws -ObservableInstrumentTests.RecordObservableInstruments_ThrowingCallback_PropagatesAggregateException_AndRecordsOthers; concurrentCreate/Dispose-CapturingMeterFactoryTests.CreateAndDisposeConcurrently_DisposesSafely_AndRejectsLaterCreate; recording racing disposal -IsolationAndConcurrencyTests.RecordingWhileDisposing_DoesNotThrow_AndStopsCapturingAfterDispose; meaningful clear/record invariants -IsolationAndConcurrencyTests.ConcurrentClearAndRecord_KeepsCaptureConsistent; culture-invariant message formatting -CultureInvarianceTests.FailureMessage_FormatsNumericValueAndTagWithInvariantCulture; unknown meter/instrument diagnostics -SelectionTests.UnknownInstrumentName_NeverPasses_AndFailingOnce_ListsExistingInstrumentsandSelectionTests.UnknownInstrumentNameInNamedMeter_ReportsTheMeterAndExistingInstruments; expected-tag-value type annotation -VerificationBuilderTests.FailureMessage_AnnotatesExpectedNonStringTagValueWithItsRuntimeType. Snapshot stability after disposal was already covered by the pre-existingSnapshotAndDisposalTests.Snapshot_StaysReadable_AfterDispose(not re-added). - ADR-0072 records these as four dated Amendments; its original Decision text is unchanged.
- Updated evidence:
Compono.Metrics.Testsnow passes 94 tests on net9.0/net10.0/net11.0 and 93 on net8.0 (the difference is the single in-boxGauge<T>test inInstrumentCaptureTests,#if NET9_0_OR_GREATER);HostRecipeTests5/5; full-solution build/test, pack-allinspect-packed-nupkgs.sh, and the Native AOT publish-and-run are green after the corrections.