Exploring the (underwhelming) System.Diagnostics.Metrics source generators

.NET 6 introduced the System.Diagnostics.Metrics APIs for recording counters, gauges and histograms, and Microsoft later shipped a source generator on top of them through the Microsoft.Extensions.Telemetry.Abstractions package. The pitch is straightforward: write an attribute on a partial method, and the generator produces strongly typed metric classes for you instead of hand rolled Meter and Instrument setup. I wanted to see how much boilerplate this generator actually removes, so I took a small ASP.NET Core sample and rewrote it twice, once by hand and once with the generator, then compared the two.

The short version is that the generator did not save me much. It moves boilerplate around rather than removing it, and in one case it makes the calling code slightly worse. I still walked through both the plain attribute version and the strongly typed tags version, since the second one solves a real problem even if the generator itself is not doing much extra work.

A quick recap of the Metrics APIs

The Metrics APIs work with two main types. An Instrument records values for one specific metric, something like product page views or invoices created. A Meter is a logical container that groups related instruments together, similar to how System.Runtime groups runtime counters and Microsoft.AspNetCore.Hosting groups request counters.

There are four instrument types you will use directly: Counter<T> for values that only go up, UpDownCounter<T> for values that go up and down, Gauge<T> for a current snapshot value, and Histogram<T> for distributions like request duration. Picking the right instrument type matters for how monitoring tools aggregate and display the metric later, so it is worth getting this choice right before you wire up dashboards around it.

The hand rolled version

Here is a small ASP.NET Core app that records a counter every time someone views a product’s pricing page. A ProductMetrics class owns the Meter and Instrument setup in its constructor, and exposes a PricingPageViewed method that the endpoint calls on every request.

using System.Diagnostics.Metrics;
using Microsoft.Extensions.Diagnostics.Metrics;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductMetrics>();
var app = builder.Build();
 
app.MapGet("/product/{id}", (int id, ProductMetrics metrics) =>
{
    metrics.PricingPageViewed(id);
    return $"Details for product {id}";
});
 
app.Run();
 
public class ProductMetrics
{
    private readonly Counter<long> _pricingDetailsViewed;
 
    public ProductMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("MyApp.Products");
        _pricingDetailsViewed = meter.CreateCounter<int>(
            "myapp.products.pricing_page_requests",
            unit: "requests",
            description: "Requests to the pricing page for a given product_id");
    }
 
    public void PricingPageViewed(int id)
    {
        _pricingDetailsViewed.Add(1, new KeyValuePair<string, object?>("product_id", id));
    }
}

The constructor asks IMeterFactory for a Meter called MyApp.Products, then creates a Counter<int> on it with a description and a unit, both of which show up nicely in tools like dotnet-counters or an OpenTelemetry exporter. The PricingPageViewed method wraps the actual Add call and attaches the product_id tag, so the endpoint just calls one method with one argument. This is roughly ten lines of setup for one metric, which is not a lot to maintain by hand even without a generator.

Installing the generator

To try the generator, add the Microsoft.Extensions.Telemetry.Abstractions package to the project.

dotnet add package Microsoft.Extensions.Telemetry.Abstractions

This package only adds compile time code generation, it does not add anything to the app at runtime beyond what System.Diagnostics.Metrics already provides. I used version 10.2.0, the latest stable release at the time of writing.

Defining a metric with an attribute

With the generator installed, you define a metric by attaching a [Counter<T>] attribute to a partial method inside a partial class called Factory. There are matching attributes for Gauge<T> and Histogram<T>.

private static partial class Factory
{
    [Counter<int>("product_id", Name = "myapp.products.pricing_page_requests")]
    internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);
}

The method itself has no body, the generator fills that in behind the scenes. This attribute only lets you pass tag names as strings, description and unit are not directly supported here. You can add a Unit, but only behind a pragma that suppresses an experimental API warning, and there is currently no way to attach a Description at all, which is a real step back from the hand rolled version.

Wiring the factory into the metrics helper

The ProductMetrics class itself also needs to change, into a partial class that calls the generated factory method from its constructor.

public partial class ProductMetrics
{
    public ProductMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("MyApp.Products");
        PricingPageViewed = Factory.CreatePricingPageViewed(meter);
    }
 
    internal PricingPageViewed PricingPageViewed { get; }
 
    private static partial class Factory
    {
        [Counter<int>("product_id", Name = "myapp.products.pricing_page_requests")]
        internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);
    }
}

Instead of exposing a PricingPageViewed method like the hand rolled version, this exposes PricingPageViewed as a property that holds the generated instrument object directly. That is a meaningful design difference, and it changes how the endpoint code calls the metric.

Calling the generated metric

The endpoint code needs a small update to match the new shape.

app.MapGet("/product/{id}", (int id, ProductMetrics metrics) =>
{
    metrics.PricingPageViewed.Add(value: 1, product_id: id);
    return $"Details for product {id}";
});

You now have to pass value: 1 explicitly, along with the tag as a named argument, instead of the single call metrics.PricingPageViewed(id) you had before. This is a minor thing on its own, but it means the generator has made the call site slightly more verbose than the version you wrote by hand, which is the opposite of what I expect from a source generator.

What the generator actually produces

It is worth looking at the generated code directly, most IDEs let you navigate straight to the definition of a partial method to see it. The Factory method calls into a second generated type that manages instrument creation.

internal static partial class GeneratedInstrumentsFactory
{
    private static ConcurrentDictionary<Meter, PricingPageViewed> _instruments = new();
 
    internal static PricingPageViewed CreatePricingPageViewed(Meter meter) =>
        _instruments.GetOrAdd(meter, static m =>
        {
            var instrument = m.CreateCounter<int>("myapp.products.pricing_page_requests");
            return new PricingPageViewed(instrument);
        });
}
 
internal sealed class PricingPageViewed
{
    private readonly Counter<int> _counter;
 
    public PricingPageViewed(Counter<int> counter) => _counter = counter;
 
    public void Add(int value, object? product_id)
    {
        var tagList = new TagList { new("product_id", product_id) };
        _counter.Add(value, tagList);
    }
}

The interesting part here is the ConcurrentDictionary keyed by Meter. The generated code is built to support the same Instrument being registered against multiple different Meter instances, deduplicating by meter reference. I do not have a good use case for that pattern myself, and it does not map cleanly onto OpenTelemetry, which has no real concept of a Meter as a separate registration boundary. If you export these metrics through OpenTelemetry, this design is more likely to cause confusing duplicate registrations than to solve a real problem.

Running the app and checking it with dotnet-counters confirms the metric records correctly either way, so functionally nothing is lost by switching to the generator.

dotnet-counters showing the pricing page metric being recorded.
dotnet-counters showing the pricing page metric being recorded.

Is the generated code worth using here?

For this simple case, I do not think so. The hand rolled version was already about ten lines of setup plus one small helper method, so a source generator is solving a boilerplate problem that barely existed here. Losing the ability to set a Description without extra ceremony, and losing the simple single argument method call at the use site, both count against it in practice.

The one scenario where the generator’s dictionary based approach might earn its keep is if you genuinely need the same instrument shared across several Meters. I cannot think of a common reason to want that in an ASP.NET Core app, so until someone shows me a real case, I would treat this as a generator solving a problem most teams do not have.

Strongly typed tag objects

The generator supports a second pattern that is more compelling: instead of listing tag names and values as separate arguments, you define a struct that represents all the tags for a metric together. This matters because passing several same typed arguments in a row, like two int IDs, is a classic source of bugs where values get swapped at the call site without the compiler noticing.

public readonly struct PricingPageTags
{
    [TagName("product_id")]
    public required string ProductId { get; init; }
    public required Environment Environment { get; init; }
}
 
public enum Environment { Development, QA, Production }

The struct is declared readonly to avoid extra allocations, and the TagName attribute lets you control the exact tag name written to the metric, since Environment here would otherwise be written with a capital E by default. You can only use string or enum types as tag values, other types are not supported.

Wiring the typed tags into the attribute

The Factory method’s attribute changes to accept a Type instead of a tag name string.

[Counter<int>(typeof(PricingPageTags), Name = "myapp.products.pricing_page_requests")]
internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);

This tells the generator to build an Add overload that takes a PricingPageTags instance instead of loose parameters.

Calling the typed version

The call site now passes a single struct instance for the tags.

metrics.PricingPageViewed.Add(1, new PricingPageTags
{
    ProductId = id.ToString(CultureInfo.InvariantCulture),
    Environment = ProductMetrics.Environment.Production
});

Notice that ProductId has to be a string here even though the underlying id is an int, since the source generator only supports string and enum tag values. That forces a ToString call, and the hand rolled version was boxing the int into an object anyway, so neither approach is free of overhead. If this metric sits on a hot path, benchmark both before assuming either one is meaningfully faster.

Does the typed tags case change my verdict?

Not much. The strongly typed struct genuinely reduces the chance of swapping tag values by accident, and that is a real win, but you can write that same struct and pass it into a hand rolled Add method without any generator involved. The generated Add method also calls ToString() on the enum internally, and enum ToString() is known to be slow compared to a manual switch expression, so the generated version is not obviously faster than code you would write yourself.

The multiple Meters per Instrument scenario is still baked into the generated dictionary lookup here too, so you are paying for a lookup you probably do not need regardless of which tagging style you pick.

My take

If you are only defining a handful of metrics in a service, I would skip this generator and write the Meter and Instrument setup by hand. You keep full control over descriptions and units without pragma workarounds, and your call sites stay as simple as a single method call. If bug prone tag ordering is a real concern on your team, borrow just the strongly typed tag struct idea and apply it to your own hand rolled Add method, you get the safety benefit without any of the generator’s extra indirection.

Where this could still be worth revisiting is once you start exporting these metrics through OpenTelemetry to a backend like Azure Monitor. That is a separate piece of plumbing on top of everything shown here, and it is the more useful next step if you are building this out for a production service rather than a demo endpoint.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading