Compono.Metrics¶
First-class System.Diagnostics.Metrics testing support. IMeterFactory composes naturally through UseMetrics(), backed by a hand-written CapturingMeterFactory (System.Diagnostics.Metrics is in-box on every target framework — there is no third-party dependency and no Microsoft.Extensions.Diagnostics.Testing). A composed factory captures every measurement of every instrument a SUT creates — counter, up/down-counter, histogram, gauge, and observable instruments when polled — and exposes Compono-native typed reads and Verify<T>() verification reusing core Compono's CallVerifier unmodified. See ADR-0072 for the full decision record and RESEARCH-0039 for the design investigation and the empirical BCL facts this package relies on.
When to install¶
Your code under test takes an IMeterFactory constructor dependency and records measurements, and the test wants to assert what was recorded — instrument names, values, and tags — not substitute an unrelated application interface or hand-write a factory plus one MetricCollector<T> per instrument:
This package depends only on Compono. System.Diagnostics.Metrics (IMeterFactory, Meter, MeterListener, Counter<T>, and so on) is part of the shared framework on every supported target framework, so no PackageReference is added — not System.Diagnostics.DiagnosticSource, not Microsoft.Extensions.Diagnostics, not Microsoft.Extensions.Diagnostics.Testing, not Microsoft.Extensions.DependencyInjection.Abstractions.
No MSBuild opt-in. Unlike Compono.TestDoubles, there is nothing to enable — Compono.Metrics ships no generator, analyzer, or props asset. UseMetrics() works as soon as the package is referenced.
What it gives you¶
using Compono;
using Compono.Metrics;
using System.Diagnostics.Metrics;
var composer = Composer.Create(builder => builder.UseMetrics());
var service = composer.Create<OrderService>(); // OrderService(IMeterFactory meters, ...)
public sealed class MetricsProfile : ICompositionProfile
{
public void Configure(CompositionBuilder builder) =>
builder.UseMetrics().Share<IMeterFactory>();
}
[Theory]
[Compose<MetricsProfile>]
public void PlacingAnOrderCountsIt([Shared] IMeterFactory meters, OrderService sut)
{
using var _ = meters; // optional on Compono.XunitV3 (JIT); see "Disposal patterns"
sut.PlaceOrder(new Order("eu", 3));
meters.Verify<long>("shop.orders.placed").WithTag("region", "eu").WithValue(1).Once();
meters.Verify<double>("shop.orders.duration").Once(); // histogram: one recording
}
var placed = meters.GetMeasurements<long>("Shop.Orders", "shop.orders.placed");
Assert.Equal(1, placed.Sum(m => m.Value)); // value math via LINQ
UseMetrics()— registers a stage-6 test-double provider so anyIMeterFactoryrequest composes as a newCapturingMeterFactory.meters.GetMeasurements<T>(instrumentName)/GetMeasurements<T>(meterName, instrumentName)— a snapshot list of the matching measurements. Direct inspection, no assertion framework required.Tis one ofbyte,short,int,long,float,double, ordecimal.meters.Verify<T>(...)— the fluent entry point:.WithValue(value),.WithTag(key, value),.WithoutTag(key),.Matching(predicate), ending in.Once()/.Never()/.Exactly(n)/.AtLeast(n)/.AtMost(n)— the exact same single-verb vocabularyCompono.TestDoubles/Compono.Http/Compono.Loggingalready use (repository.Verify().Save().Once(),logger.Verify().AtLevel(...).Once()), each a thin, one-line forward through the same shared count-verification semantics coreCompono'sCallVerifierimplements. The filter chain narrows which measurements count, then the terminal counts them exactly the same way regardless of which package it's reached through.meters.RecordObservableInstruments()— polls this factory's observable instruments. Reads never poll; see "Observable instruments."meters.ClearMeasurements()— empties the capture buffer. Order is otherwise capture order; see "Capture order." It's also the supported way to assert on one pass of a multi-pass test; see "Asserting on one pass."CapturingMeterFactory— hand-written and publicly, directly constructible with no composition involved at all: AUseMetrics()-composed factory and a directly-constructed one behave identically — one implementation, not two.
Factory semantics¶
CapturingMeterFactory has parity with the BCL's own DefaultMeterFactory:
Create(null)throwsArgumentNullException.Createwith a non-nulloptions.Scopethat isn't this factory throwsInvalidOperationException.CreateafterDispose()throwsObjectDisposedException.- The caller's
MeterOptionsis never mutated. The meter is constructed from a copy whoseScopeis this factory, someter.Scopeis reference-equal to the factory. - Meters are cached: the same name, version, and tag set (order-insensitive,
object.Equalson values) returns the sameMeter. Anything else creates a new one. - Thread-safe.
Capture semantics¶
- Listener. Each factory owns one
MeterListener, created on its firstCreateand started before the meter is returned. It enables an instrument only whenReferenceEquals(instrument.Meter.Scope, this), so two factories never see each other's measurements even for identical meter and instrument names. There is no static or shared capture state. A factory that never creates a meter never creates a listener. - What's captured. Every measurement of every enabled instrument kind:
Counter<T>,UpDownCounter<T>,Histogram<T>,Gauge<T>(net9.0+), and observable instruments when polled. Raw measurements only — Compono doesn't aggregate, doesn't interpret kind (no delta-vs-last-value semantics), and doesn't read histogram bucket advice. - No timestamp. Order is capture order (see "Capture order").
- Isolation. Two factories never see each other's measurements.
Selection and matching¶
GetMeasurements<T> and Verify<T> resolve their selection with the same algorithm, in this order. The first rule that applies decides the outcome. Verify<T> resolves it when the builder is created, so a conflict throws before any filter is added.
- Arguments. A null or empty
instrumentName/meterNamethrowsArgumentException. ATthat isn't one of the seven BCL measurement types throwsArgumentExceptionwhether or not any instrument exists; the message names the requested type and lists the seven supported types. - Candidates. Every instrument this factory has published whose
NameequalsinstrumentNameand, with the two-argument overload, whoseMeter.NameequalsmeterName. Both comparisons are ordinal and case-sensitive. The candidate set includes instruments of every value type, not justT. - Unknown name. No candidates yields an empty result.
Verify(...).Never()passes. A failingOnce()/Exactly(n)/AtLeast(n)message lists the instruments that do exist (meter name, instrument name, value type), so a typo is visible. - Meter ambiguity (one-argument overload only). Candidates from meters with different
Meter.Namevalues throwInvalidOperationException. The message lists each candidate and points at the two-argument overload. - Type conflict. Candidates that record more than one value type throw
InvalidOperationException, even when one of the types isT. This can happen inside one meter (CreateCounter<int>("x")andCreateCounter<long>("x")are distinct instruments) or across same-named meters with different versions or tags. - Type mismatch. Candidates that all record one value type other than
TthrowInvalidOperationExceptionnaming that type, for exampleinstrument "shop.orders.placed" records int, not long. - Merge. Otherwise every candidate records
T. Their measurements are merged in capture order.
Matching filters:
WithValue(value)usesEqualityComparer<T>.Default. Floating-point equality is exact; useMatching(...)for tolerance.WithTag(key, value)matches a measurement that contains a tag with that key (ordinal) whose value satisfiesobject.Equals(tagValue, value). Other tags are ignored. There is no numeric coercion:200(int) does not match200L. Chain calls for several tags.WithoutTag(key)matches a measurement with no tag with that key.Matching(predicate)covers exact tag-set and any other condition.
Failure messages follow LogVerificationBuilder's style: the filters are joined with with/and, and the count forwards to core CallVerifier's Expected exactly N call(s) to ..., but received M. wording. Numeric values are formatted with the invariant culture. The message describes the filter you asked for. A string tag value is shown quoted (region = "eu"); any other value shows the runtime type of the value you passed to WithTag (for example 200 renders as 200 (int)), so it always matches the expectation and never annotates the measurement's actual tag. For example:
Expected exactly 1 call(s) to instrument "shop.orders.placed" (long) with tag region = "eu" and value 1, but received 0.
Expected exactly 1 call(s) to instrument "shop.orders.placed" (long) with tag status = 200 (int), but received 0.
When nothing matched an unknown instrument name, the message names the instrument that doesn't exist and lists the ones that do:
Expected exactly 1 call(s) to instrument "shop.orders.placed" (long), but received 0. No instrument named "shop.orders.placed" has been published by this factory. Existing instruments: meter "Shop.Orders": instrument "orders.placed" (long).
These exceptions are InvalidOperationException/ArgumentException with descriptive messages; no new public exception type is added.
Observable instruments¶
RecordObservableInstruments()calls this factory's listener'sRecordObservableInstruments(). Callbacks run synchronously on the calling thread and only for this factory's observable instruments. Each observation appends measurements. Calling it twice records twice.- Reads (
GetMeasurements,Verify) never poll and have no side effects. - If a callback throws, the BCL behavior stands: the other callbacks still run, then
AggregateExceptionpropagates to the test. Compono doesn't catch it. - Before the first
Create, or after disposal, it is a no-op.
Asynchronous measurements¶
Measurements recorded on other threads (background work, hosted services) are captured thread-safely as they arrive. Reads are point-in-time snapshots. Compono provides no wait or timeout API — the test awaits its own completion signal before asserting.
Disposal patterns¶
No ownership or disposal API is added. Composition is non-owning (UseMetrics() composes a new factory per request and never disposes it). IMeterFactory already extends IDisposable, and Dispose() disposes every meter the factory created, then the listener. It is idempotent and thread-safe, so a test framework that also disposes the argument is harmless. The documented patterns:
[Compose]-family tests: dispose the[Shared]parameter in the test body, unless the test framework already does.
| Adapter | Disposes IDisposable test-method arguments? | using var _ = meters; |
|---|---|---|
Compono.XunitV3 (xUnit v3, JIT) | Yes. xUnit disposes every IDisposable/IAsyncDisposable argument after the test case, once per argument slot | Optional (harmless) |
Compono.XunitV3.Aot (xUnit v3 Native AOT) | No | Required |
Compono.TUnit | Documented, not re-tested here: TUnit disposes a composed root method argument itself (Compono.TUnit — Disposal, ADR-0040). ADR-0072 Amendment 6 didn't re-run that check | Optional by that documented behavior; keeping it is harmless |
Compono.NUnit, Compono.MSTest | Not verified | Keep it (harmless; Dispose() is idempotent) |
That disposal is xUnit's own behavior, not a Compono contract. Compono registers nothing for disposal. With [Shared], the parameter is the SUT's factory, so xUnit disposing the argument disposes the SUT's meters too. Without [Shared], the SUT's factory is a different instance, and nothing disposes it. See ADR-0072 Amendment 6.
[Theory, Compose<MetricsProfile>]
public void PlacingAnOrderCountsIt([Shared] IMeterFactory meters, OrderService sut)
{
using var _ = meters;
sut.PlaceOrder(new Order("eu", 3));
meters.Verify<long>("shop.orders.placed").Once();
}
[Shared] guarantees the parameter is the same instance the SUT holds, so disposing it disposes everything the SUT created through it. The using declaration runs when the method exits, including when an assertion throws. Assertions can go anywhere in the body because captured data stays readable after disposal. In an async test, using var works the same way and runs after the last await. IMeterFactory isn't IAsyncDisposable, so await using doesn't apply. On Compono.XunitV3, you can drop the line. xUnit disposes the argument after the test case, which is after every assertion has run.
Composer.Createtests: construct the factory, then register it.
using var meters = new CapturingMeterFactory();
var composer = Composer.Create(b => b.Register<IMeterFactory>(() => meters));
var sut = composer.Create<OrderService>();
An exact registration takes precedence over UseMetrics(), so UseMetrics() isn't needed here. The test creates the instance, so it owns it.
- Generic Host / DI: the same, registered as a singleton instance.
using var meters = new CapturingMeterFactory();
var builder = Host.CreateApplicationBuilder();
builder.Services.AddSingleton<IMeterFactory>(meters); // last IMeterFactory registration wins
builder.Services.AddSingleton<OrderService>();
using var host = builder.Build();
// ... start the host, call the code under test ...
meters.Verify<long>("shop.orders.placed").Once();
Host.CreateApplicationBuilder() already registers a default IMeterFactory as part of its built-in service setup, so IMeterFactory has two registrations once you add yours. The container resolves a single IMeterFactory to its last registration, so the host resolves meters. Don't use TryAddSingleton to install it: TryAdd keeps whatever is registered first, so it would leave the default factory in place and discard yours. An instance registration isn't disposed by the container, so the test's using owns it. Dispose the factory after the host has stopped, so measurements recorded during host shutdown are still captured.
Dispose after the SUT's work is done. Measurements recorded after disposal aren't captured, so disposing while background work is still running can drop measurements. Consequence of skipping disposal: an undisposed factory that created meters stays referenced by the BCL's static meter list for the process lifetime — the same cost as an undisposed DefaultMeterFactory. A factory that never created a meter costs nothing.
Asserting on one pass¶
A test that drives several passes over the same SUT (an outbox processor run three times against one database, say) and asserts only on the last pass calls ClearMeasurements() immediately before the step under test. Everything recorded before that point is discarded, so the verification reads as "measurements from this pass":
[Theory, Compose<OutboxProfile>]
public async Task ThirdPassPublishesTheRemainingMessage([Shared] IMeterFactory meters, OutboxProcessor sut)
{
await sut.RunOnceAsync(); // pass 1: arrange
await sut.RunOnceAsync(); // pass 2: arrange
meters.ClearMeasurements(); // act boundary: only the pass under test counts from here
await sut.RunOnceAsync(); // pass 3: the step under test
meters.Verify<long>("outbox.published").WithValue(1).Once();
}
This is the same reset idiom as Compono.TestDoubles ClearCalls() and Compono.Logging ClearCapturedEntries(). ClearMeasurements() doesn't change a snapshot list already returned by GetMeasurements<T>, so to assert on an earlier pass too, take that snapshot before clearing. Checkpoint or scoped-capture APIs are deferred until a test needs measurements from several phases kept and asserted independently (RESEARCH-0040 finding 4).
Snapshot lifetime¶
GetMeasurements<T>returns a new list each call. Later captures,ClearMeasurements(), andDispose()never change a list already returned.- Each
Tagslist is a per-measurement copy taken in the callback; it never aliases the BCL's tag span or another measurement's tags. A measurement with no tags yieldsTagsthat is empty, never null. CapturedMeasurement<T>.Instrumentis the actualInstrumentobject the SUT created. ThroughInstrument.Meterit references the actualMeter, and throughMeter.Scopethe factory. That's ordinary object reachability, not a registration; it causes no capture.- Snapshots stay valid after the factory is disposed.
Value,Tags, andInstrument's descriptive members remain readable; a disposed meter's instruments reportEnabled == false, and recording through them is a no-op. Reading a snapshot never throws because of disposal.
Capture order¶
- Capture order is the order in which measurements are successfully inserted into the factory's synchronized capture buffer.
- Each measurement gets a factory-wide sequence number, assigned inside the same lock acquisition that appends it. Merged selections and every snapshot are ordered by that sequence number.
- Measurements recorded sequentially on one thread are captured in that thread's program order. One
RecordObservableInstruments()call appends in the order the BCL invokes the callbacks and their observations. - No chronological ordering is claimed across concurrent producers. Two threads recording at about the same time may be captured in either order.
ClearMeasurements()doesn't reset the sequence. Order is only meaningful within one factory.- One private lock per factory guards appends, clears, and reads; the tag copy happens before the lock is taken.
Registration order and misuse¶
UseMetrics()registers a stage-6 provider that handles exactlytypeof(IMeterFactory)and returnsNotHandledfor everything else. Precedence againstUseNSubstitute()(or any other catch-all stage-6 provider) follows the existing first-registered-wins rule (ADR-0024). RegisterUseMetrics()first, especially in a shared base profile:Compono.MetricsownsIMeterFactory, so order againstUseGeneratedTestDoubles()doesn't matter. Referencing the package sets the MSBuild propertyComponoMetricsOwnsMeterFactorytotrueby default.Compono.TestDoublesthen never generates a double forIMeterFactory, even withComponoGeneratedTestDoubles=true, and bothUseGeneratedTestDoubles().UseMetrics()andUseMetrics().UseGeneratedTestDoubles()compose aCapturingMeterFactory. Other interfaces still get their generated doubles (ADR-0072 Amendment 5).- Tradeoff: with the package referenced, a composer that calls
UseGeneratedTestDoubles()but notUseMetrics()gets noIMeterFactorydouble. The request falls through to a later stage-6 provider (such as NSubstitute), or composition fails with aCompositionExceptionnamingIMeterFactory. AddUseMetrics(). - Opt out: to get a generated
IMeterFactorydouble back, set<ComponoMetricsOwnsMeterFactory>false</ComponoMetricsOwnsMeterFactory>in the test project. Order againstUseGeneratedTestDoubles()then matters again. - Every
MeterFactoryTestingExtensionsmethod throwsInvalidOperationExceptionwhen theIMeterFactoryisn't aCapturingMeterFactory. The message namesUseMetrics(), the registration-order requirement for catch-all providers, theComponoMetricsOwnsMeterFactoryownership rule, and the host recipe. It's detected by a type check, not reflection. Meterconstructor parameters are not composed by this package. Consumers that want a specificMeterregister it from the shared factory in a profile.
Native AOT and trimming¶
No reflection, no MakeGenericType, no dynamic code, no generated activation. IsAotCompatible=true, so the library builds with the trim/AOT analyzers on every target framework. The Compono.Metrics.AotSmokeTest project publishes with PublishAot=true for linux-x64 on net10.0 in CI, matching the existing smoke matrix.
v1 non-goals¶
Deliberately not in this package — see ADR-0072's Decision Outcome for the rationale behind each:
- A metrics framework, aggregation engine, exporter, or OpenTelemetry integration.
- Tracing (
ActivitySource) testing. - Composing
Meterparameters, or a configurable meter name. - An
IServiceCollection/host extension (documentation recipe instead). - Timestamps,
TimeProvider, and wait/timeout APIs. - Aggregate helpers (
Sum,LastValue) and numeric tag coercion. - Automatic disposal by composition.
- Version- or tag-level meter selection. Same-named instruments with conflicting value types in same-named meters can't be read at all; use
Matching(m => m.Instrument.Meter.Version == "2")after the types agree. - Replacing
Microsoft.Extensions.Diagnostics.Testingfor consumers who prefer it.
Next¶
- Migrating from Microsoft.Extensions.Diagnostics.Testing —
AddFakeLogging/FakeLogCollector/MetricCollector<T>mapped onto Compono, with registration order and disposal. - Basic Usage sample (
MetricsTests) — a conciseUseMetrics()/Verify<T>()scenario in this repo's single clearest reference project. - Verify Metrics Across a Host — the Generic Host recipe.
Compono.XunitV3/Compono.TUnit—[Compose]/[Shared]used throughout the examples above.Compono.Logging— the same "composed double is the handle,Verify()forwards toCallVerifier" idiom for logging.- ADR-0072 — the full decision record.