Skip to content

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:

dotnet add package Compono
dotnet add package Compono.Metrics

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 any IMeterFactory request composes as a new CapturingMeterFactory.
  • meters.GetMeasurements<T>(instrumentName) / GetMeasurements<T>(meterName, instrumentName) — a snapshot list of the matching measurements. Direct inspection, no assertion framework required. T is one of byte, short, int, long, float, double, or decimal.
  • 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 vocabulary Compono.TestDoubles/Compono.Http/Compono.Logging already use (repository.Verify().Save().Once(), logger.Verify().AtLevel(...).Once()), each a thin, one-line forward through the same shared count-verification semantics core Compono's CallVerifier implements. 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:
    using var meters = new CapturingMeterFactory();
    var sut = new OrderService(meters);
    
    A UseMetrics()-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) throws ArgumentNullException. Create with a non-null options.Scope that isn't this factory throws InvalidOperationException. Create after Dispose() throws ObjectDisposedException.
  • The caller's MeterOptions is never mutated. The meter is constructed from a copy whose Scope is this factory, so meter.Scope is reference-equal to the factory.
  • Meters are cached: the same name, version, and tag set (order-insensitive, object.Equals on values) returns the same Meter. Anything else creates a new one.
  • Thread-safe.

Capture semantics

  • Listener. Each factory owns one MeterListener, created on its first Create and started before the meter is returned. It enables an instrument only when ReferenceEquals(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.

  1. Arguments. A null or empty instrumentName/meterName throws ArgumentException. A T that isn't one of the seven BCL measurement types throws ArgumentException whether or not any instrument exists; the message names the requested type and lists the seven supported types.
  2. Candidates. Every instrument this factory has published whose Name equals instrumentName and, with the two-argument overload, whose Meter.Name equals meterName. Both comparisons are ordinal and case-sensitive. The candidate set includes instruments of every value type, not just T.
  3. Unknown name. No candidates yields an empty result. Verify(...).Never() passes. A failing Once()/Exactly(n)/AtLeast(n) message lists the instruments that do exist (meter name, instrument name, value type), so a typo is visible.
  4. Meter ambiguity (one-argument overload only). Candidates from meters with different Meter.Name values throw InvalidOperationException. The message lists each candidate and points at the two-argument overload.
  5. Type conflict. Candidates that record more than one value type throw InvalidOperationException, even when one of the types is T. This can happen inside one meter (CreateCounter<int>("x") and CreateCounter<long>("x") are distinct instruments) or across same-named meters with different versions or tags.
  6. Type mismatch. Candidates that all record one value type other than T throw InvalidOperationException naming that type, for example instrument "shop.orders.placed" records int, not long.
  7. Merge. Otherwise every candidate records T. Their measurements are merged in capture order.

Matching filters:

  • WithValue(value) uses EqualityComparer<T>.Default. Floating-point equality is exact; use Matching(...) for tolerance.
  • WithTag(key, value) matches a measurement that contains a tag with that key (ordinal) whose value satisfies object.Equals(tagValue, value). Other tags are ignored. There is no numeric coercion: 200 (int) does not match 200L. 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's RecordObservableInstruments(). 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 AggregateException propagates to the test. Compono doesn't catch it.
  • Before the first Create, or after disposal, it is a no-op.
meters.RecordObservableInstruments();
meters.Verify<int>("shop.queue.depth").WithValue(7).Once();

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:

  1. [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.

  1. Composer.Create tests: 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.

  1. 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(), and Dispose() never change a list already returned.
  • Each Tags list 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 yields Tags that is empty, never null.
  • CapturedMeasurement<T>.Instrument is the actual Instrument object the SUT created. Through Instrument.Meter it references the actual Meter, and through Meter.Scope the factory. That's ordinary object reachability, not a registration; it causes no capture.
  • Snapshots stay valid after the factory is disposed. Value, Tags, and Instrument's descriptive members remain readable; a disposed meter's instruments report Enabled == 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 exactly typeof(IMeterFactory) and returns NotHandled for everything else. Precedence against UseNSubstitute() (or any other catch-all stage-6 provider) follows the existing first-registered-wins rule (ADR-0024). Register UseMetrics() first, especially in a shared base profile:
    builder.UseMetrics().UseNSubstitute();   // IMeterFactory -> CapturingMeterFactory
    builder.UseNSubstitute().UseMetrics();   // IMeterFactory -> a substitute instead
    
  • Compono.Metrics owns IMeterFactory, so order against UseGeneratedTestDoubles() doesn't matter. Referencing the package sets the MSBuild property ComponoMetricsOwnsMeterFactory to true by default. Compono.TestDoubles then never generates a double for IMeterFactory, even with ComponoGeneratedTestDoubles=true, and both UseGeneratedTestDoubles().UseMetrics() and UseMetrics().UseGeneratedTestDoubles() compose a CapturingMeterFactory. Other interfaces still get their generated doubles (ADR-0072 Amendment 5).
  • Tradeoff: with the package referenced, a composer that calls UseGeneratedTestDoubles() but not UseMetrics() gets no IMeterFactory double. The request falls through to a later stage-6 provider (such as NSubstitute), or composition fails with a CompositionException naming IMeterFactory. Add UseMetrics().
  • Opt out: to get a generated IMeterFactory double back, set <ComponoMetricsOwnsMeterFactory>false</ComponoMetricsOwnsMeterFactory> in the test project. Order against UseGeneratedTestDoubles() then matters again.
  • Every MeterFactoryTestingExtensions method throws InvalidOperationException when the IMeterFactory isn't a CapturingMeterFactory. The message names UseMetrics(), the registration-order requirement for catch-all providers, the ComponoMetricsOwnsMeterFactory ownership rule, and the host recipe. It's detected by a type check, not reflection.
  • Meter constructor parameters are not composed by this package. Consumers that want a specific Meter register 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 Meter parameters, 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.Testing for consumers who prefer it.

Next