{"slug": "exploring-the-underwhelming-system-diagnostics-metrics-source-generators-system", "title": "Exploring the (underwhelming) System.Diagnostics.Metrics source generators: System.Diagnostics.Metrics APIs - Part 2", "summary": "The article explains how to use the `Microsoft.Extensions.Telemetry.Abstractions` source generator to simplify metric creation in .NET, replacing manual boilerplate code from the `System.Diagnostics.Metrics` APIs introduced in .NET 6. It demonstrates updating a sample app that tracks product page views by using the source generator to automatically generate meter and instrument definitions, and also shows how to implement strongly-typed tag objects for improved type-safety. The post includes a refresher on core concepts like `Instrument` and `Meter`, and explores the generated code to highlight the reduction in manual coding required.", "body_md": "In my [previous post](/creating-and-consuming-metrics-with-system-diagnostics-metrics-apis/) I provided an introduction to the *System.Diagnostics.Metrics* APIs introduced in .NET 6. In this post I show how to use the [Microsoft.Extensions.Telemetry.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.Telemetry.Abstractions) source generator, explore how it changes the code you need to write, and explore the generated code.\n\nI start the post with a quick refresher on the basics of the *System.Diagnostics.Metrics* APIs and the sample app we wrote [last time](/creating-and-consuming-metrics-with-system-diagnostics-metrics-apis/). I then show how we can update this code to use the *Microsoft.Extensions.Telemetry.Abstractions* source generator instead. Finally, I show how we can also update our metric definitions to use strongly-typed tag objects for additional type-safety. In both cases, we'll update our sample app to use the new approach, and explore the generated code.\n\nYou can read about the source generators I discuss in this post in the Microsoft documentation\n\n[here]and[here].\n\n[Background: System.Diagnostics.Metrics APIs](#background-system-diagnostics-metrics-apis)\n\nThe *System.Diagnostics.Metrics* APIs were introduced in .NET 6 but are available in earlier runtimes (including .NET Framework) by using the [ System.Diagnostics.DiagnosticSource](https://www.nuget.org/packages/System.Diagnostics.DiagnosticSource/) NuGet package. There are two primary concepts exposed by these APIs;\n\n`Instrument`\n\nand `Meter`\n\n:`Instrument`\n\n: An instrument records the values for a single metric of interest. You might have separate`Instrument`\n\ns for \"products sold\", \"invoices created\", \"invoice total\", or \"GC heap size\".`Meter`\n\n: A`Meter`\n\nis a logical grouping of multiple instruments. For example, thecontains multiple`System.Runtime`\n\n`Meter`\n\n`Instrument`\n\ns about the workings of the runtime, while[the](https://learn.microsoft.com/en-us/aspnet/core/log-mon/metrics/built-in?view=aspnetcore-10.0#microsoftaspnetcorehosting)contains`Microsoft.AspNetCore.Hosting`\n\n`Meter`\n\n`Instrument`\n\ns about the HTTP requests received by ASP.NET Core.\n\nThere are also multiple types of `Instrument`\n\n: `Counter<T>`\n\n, `UpDownCounter<T>`\n\n, `Gauge<T>`\n\n, and `Histogram<T>`\n\n(as well as \"observable\" versions, which I'll cover in a future post). To create a custom metric, you need to choose the type of `Instrument`\n\nto use, and associate it with a `Meter`\n\n. In my [previous post](/creating-and-consuming-metrics-with-system-diagnostics-metrics-apis/) I created a simple `Counter<T>`\n\nfor tracking how often a product page was viewed.\n\n[Background: sample app with manual boilerplate](#background-sample-app-with-manual-boilerplate)\n\nIn this post I'm going to start from where we left off in the previous post, and update it to use a source generator instead. So that we know where we're coming from, the full code for that sample is shown below, annotated to explain what's going on; for the full details, see my [previous post](/creating-and-consuming-metrics-with-system-diagnostics-metrics-apis/)\n\n```\nusing System.Diagnostics.Metrics;\nusing Microsoft.Extensions.Diagnostics.Metrics;\n\nvar builder = WebApplication.CreateBuilder(args);\n\n// 👇 Register our \"metrics helper\" in DI\nbuilder.Services.AddSingleton<ProductMetrics>();\n\nvar app = builder.Build();\n\n// Inject the \"metrics helper\" into the API handler 👇 \napp.MapGet(\"/product/{id}\", (int id, ProductMetrics metrics) =>\n{\n    metrics.PricingPageViewed(id); // 👈 Record the metric\n    return $\"Details for product {id}\";\n});\n\napp.Run();\n\n// The \"metrics helper\" class for our metrics\npublic class ProductMetrics\n{\n    private readonly Counter<long> _pricingDetailsViewed;\n\n    public ProductMetrics(IMeterFactory meterFactory)\n    {\n        // Create a meter called MyApp.Products\n        var meter = meterFactory.Create(\"MyApp.Products\");\n\n        // Create an instrument, and associate it with our meter\n        _pricingDetailsViewed = meter.CreateCounter<int>(\n            \"myapp.products.pricing_page_requests\",\n            unit: \"requests\",\n            description: \"The number of requests to the pricing details page for the product with the given product_id\");\n\n    }\n\n    // A convenience method for adding to the metric\n    public void PricingPageViewed(int id)\n    {\n        // Ensure we add the correct tag to the metric\n        _pricingDetailsViewed.Add(delta: 1, new KeyValuePair<string, object?>(\"product_id\", id));\n    }\n}\n```\n\nIn summary, we have a `ProductMetrics`\n\n\"metrics helper\" class which is responsible for creating the `Meter`\n\nand `Instrument`\n\ndefinitions, as well as providing helper methods for recording page views.\n\nWhen we run the app and [monitor it with dotnet-counters](/creating-and-consuming-metrics-with-system-diagnostics-metrics-apis/#collecting-metrics-with-dotnet-counters) we can see our metric being recorded:\n\nNow that we have our sample app ready, lets explore replacing some of the boilerplate with a source generator.\n\n[Replacing boiler plate with a source generator](#replacing-boiler-plate-with-a-source-generator)\n\nThe [Microsoft.Extensions.Telemetry.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.Telemetry.Abstractions) NuGet package includes a source generator which, according to [the documentation](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/metrics-generator?tabs=dotnet-cli), generates code which:\n\n…exposes strongly typed metering types and methods that you can invoke to record metric values. The generated methods are implemented in a highly efficient form, which reduces computation overhead as compared to traditional metering solutions.\n\nIn this section we'll replace some of the code we wrote above with the source generated equivalent!\n\nFirst you'll need to install the [Microsoft.Extensions.Telemetry.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.Telemetry.Abstractions) package in your project using:\n\n```\ndotnet add package Microsoft.Extensions.Telemetry.Abstractions\n```\n\nAlternatively, update your project with a `<PackageReference>`\n\n:\n\n```\n<ItemGroup>\n  <PackageReference Include=\"Microsoft.Extensions.Telemetry.Abstractions\" Version=\"10.2.0\" />\n</ItemGroup>\n```\n\nNote that in this post I'm using the latest stable version of the package, 10.2.0.\n\nNow that we have the source generator running in our app, we can put it to use.\n\n[Creating the \"metrics helper\" class](#creating-the-metrics-helper-class)\n\nThe main difference when you switch to the source generator is in the \"metrics helper\" class. There's a lot of different ways you *could* structure these—what I've shown below is a relatively close direct conversion of the previous code. But as I'll discuss later, this isn't necessarily the way you'll always want to use them.\n\nAs is typical for source generators, the metrics generator is driven by specific attributes. There's a different attribute for each `Instrument`\n\ntype, and you apply them to a `partial`\n\nmethod definition which creates a strongly-typed metric, called `PricingPageViewed`\n\nin this case:\n\n```\nprivate static partial class Factory\n{\n    [Counter<int>(\"product_id\", Name = \"myapp.products.pricing_page_requests\")]\n    internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);\n}\n```\n\nThe example above uses the `[Counter<T>]`\n\nattribute, but there are equivalent versions for `[Gauge<T>]`\n\nand `[Histogram<T>]`\n\ntoo.\n\nThis creates the \"factory\" methods for defining a metric, but we still need to update the `ProductMetrics`\n\ntype to *use* this factory method instead of our hand-rolled versions:\n\n```\n// Note, must be partial\npublic partial class ProductMetrics\n{\n    public ProductMetrics(IMeterFactory meterFactory)\n    {\n        var meter = meterFactory.Create(\"MyApp.Products\");\n        PricingPageViewed = Factory.CreatePricingPageViewed(meter);\n    }\n\n    internal PricingPageViewed PricingPageViewed { get; }\n\n    private static partial class Factory\n    {\n        [Counter<int>(\"product_id\", Name = \"myapp.products.pricing_page_requests\")]\n        internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);\n    }\n}\n```\n\nIf you compare that to the code we wrote previously, there are two main differences:\n\n- The\n`[Counter<T>]`\n\nattribute is missing the \"description\" and \"units\" that we previously added. - The\n`PricingPageViewed`\n\nmetric is exposed directly (which we'll look at shortly), instead of exposing a`PricingPageViewed()`\n\nmethod for recording values.\n\nThe first point is just a limitation of the current API. We actually *can* specify the units on the attribute, but if we do, we need to add a `#pragma`\n\nas this API is currently experimental:\n\n```\nprivate static partial class Factory\n{\n    #pragma warning disable EXTEXP0003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.\n\n                                                        //   Add the Unit here 👇\n    [Counter<int>(\"product_id\", Name = \"myapp.products.pricing_page_requests\", Unit = \"views\")]\n    internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);\n}\n```\n\nThe second point is more interesting, and we'll dig into it when we look at the generated code.\n\n[Updating our app](#updating-our-app)\n\nBefore we get to the generated code, lets look at how we use our updated `ProductMetrics`\n\n. We keep the existing DI registration of our `ProductMetrics`\n\ntype, the only change is how we *record* a view of the page\n\n```\nusing System.Diagnostics.Metrics;\nusing System.Globalization;\nusing Microsoft.Extensions.Diagnostics.Metrics;\n\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddSingleton<ProductMetrics>();\nvar app = builder.Build();\n\napp.MapGet(\"/product/{id}\", (int id, ProductMetrics metrics) =>\n{\n    // Update to call PricingPageViewed.Add() instead of PricingPageViewed(id)\n    metrics.PricingPageViewed.Add(value: 1, product_id: id);\n    return $\"Details for product {id}\";\n});\n\napp.Run();\n```\n\nAs you can see, there's not much change there. Instead of calling `PricingPageViewed(id)`\n\n, which internally adds a metric and tag, we call the `Add()`\n\nmethod, which is a source-generated method on the `PricingPageViewed`\n\ntype. Let's take a look at all that generated code now, so we can see what's going on behind the scenes.\n\n[Exploring the generated code](#exploring-the-generated-code)\n\nWe have various generated methods to look at, so we'll start with our factory methods and work our way through from there.\n\nNote that in most IDEs you can navigate to the definitions of these partial methods and they'll show you the generated code.\n\nStarting with our `Factory`\n\nmethod, the generated code looks like this:\n\n```\npublic partial class ProductMetrics \n{\n    private static partial class Factory \n    {\n        internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter)\n            => GeneratedInstrumentsFactory.CreatePricingPageViewed(meter);\n    }\n}\n```\n\nSo the generated code is calling a *different* generated type, which looks like this:\n\n```\ninternal static partial class GeneratedInstrumentsFactory\n{\n    private static ConcurrentDictionary<Meter, PricingPageViewed> _pricingPageViewedInstruments = new();\n\n    internal static PricingPageViewed CreatePricingPageViewed(Meter meter)\n    {\n        return _pricingPageViewedInstruments.GetOrAdd(meter, static _meter =>\n            {\n                var instrument = _meter.CreateCounter<int>(@\"myapp.products.pricing_page_requests\", @\"views\");\n                return new PricingPageViewed(instrument);\n            });\n    }\n}\n```\n\nThis definition shows something interesting, in that it shows the source generator is catering to a pattern I was somewhat surprised to see. This code seems to be catering to adding the same `Instrument`\n\nto *multiple* `Meter`\n\ns.\n\nThat seems a little surprising to me, but that's possibly because I'm used to thinking in terms of OpenTelemetry expectations, which doesn't have the concept of\n\n`Meter`\n\ns (as far as I know), and completely ignores it. It seems like you would get some weird duplication issues if you tried to use this source-generator-suggested pattern with OpenTelemetry, so I personally wouldn't recommend it.\n\nOther than the \"dictionary\" aspect, this generated code is basically creating the `Counter`\n\ninstance, just as we were doing before, but is then passing it to a different generated type, the `PricingPageViewed`\n\ntype:\n\n```\ninternal sealed class PricingPageViewed\n{\n    private readonly Counter<int> _counter;\n    public PricingPageViewed(Counter<int> counter)\n    {\n        _counter = counter;\n    }\n\n    public void Add(int value, object? product_id)\n    {\n        var tagList = new TagList\n        {\n            new KeyValuePair<string, object?>(\"product_id\", product_id),\n        };\n\n        _counter.Add(value, tagList);\n    }\n}\n```\n\nThis generated type provides roughly the same \"public\" API for recording metrics as we provided before:\n\n```\npublic class ProductMetrics\n{\n    // Previous implementation\n    public void PricingPageViewed(int id)\n    {\n        _pricingDetailsViewed.Add(delta: 1, new KeyValuePair<string, object?>(\"product_id\", id));\n    }\n}\n```\n\nHowever, there are some differences. The generated code uses a more \"generic\" version that wraps the type in a `TagList`\n\n. This is a `struct`\n\n, which can support adding multiple tags without needing to allocate an array on the heap, so it's *generally* very efficient. But in this case, it doesn't add anything over the \"manual\" version I implemented.\n\nSo given all that, is this generated code actually *useful*?\n\n[Is the generated code worth it?](#is-the-generated-code-worth-it-)\n\nI love source generators, I think they're a great way to reduce boilerplate and make code easier to read and write in many cases, but frankly, I don't really see the value of this metrics source generator.\n\nFor a start, the source generator is only really changing how we define and create metrics. Which is generally 1 line of code to create the metric, and then a helper method for defining the tags etc (i.e. the `PricingPageViewed()`\n\nmethod). Is a source generator *really* necessary for that?\n\nAlso, the generator is limited in the API it provides compared to calling the *System.Diagnostics.Metrics* APIs directly. You can't provide a `Description`\n\nfor a metric, for example, and providing a `Unit`\n\nneeds a `#pragma`\n\n…\n\nWhat's more, the fact that the generated code is generic, means that the resulting usability is actually *worse* in my example, because you have to call:\n\n```\nmetrics.PricingPageViewed.Add(value: 1, product_id: id);\n```\n\nand specify an \"increment\" value, as opposed to simply being\n\n```\nmetrics.PricingPageViewed(productId: id);\n```\n\n(also note the \"correct\" argument names in my \"manual case\"). The source generator also seems to support scenarios that I don't envision needing (the same `Instrument`\n\nregistered with multiple `Meter`\n\n), so that's extra work that need not happen in the source generated case.\n\nSo unfortunately, in this simple example, the source generator seems like a net loss. But there's an additional scenario it supports: strongly-typed tag objects\n\n[Using strongly-typed tag objects](#using-strongly-typed-tag-objects)\n\nThere's a common programming bug when calling methods that have multiple parameters of the same type: accidentally passing values in the wrong position:\n\n```\nAdd(order.Id, product.Id); // Oops, those are wrong, but it's not obvious!\n\npublic void Add(int productId, int orderId) { /* */ }\n```\n\nOne partial solution to this issue is to use strongly-typed objects to try to make the mistake more obvious. For example, if the method above instead took an object:\n\n```\npublic void Add(Details details) { /* */ }\n\npublic readonly struct Details\n{\n    public required int OrderId { get; init; }\n    public required int ProductId { get; init; }\n}\n```\n\nThen at the callsite, you're *less* likely to make the same mistake:\n\n```\n// Still wrong, but the error is more obvious! 😅\nAdd(new()\n{\n    OrderId = product.Id,\n    ProductId = order.Id,\n});\n```\n\nIt turns out that passing lots of similar values is exactly the issue you run into when you need to add multiple tags when recording a value with an `Instrument`\n\n. To help with this, the source generator code can optionally use strongly-typed tag objects instead of a list of parameters.\n\n[Updating the holder class with strongly-typed tags](#updating-the-holder-class-with-strongly-typed-tags)\n\nIn the examples I've shown so far, I've only been attaching a single tag to the `PricingPageViewed`\n\nmetric, but I'll add an additional one, `environment`\n\njust for demonstration purposes.\n\nLet's again start by updating the `Factory`\n\nclass to use a strongly-typed object instead of \"manually\" defining the tags:\n\n```\nprivate static partial class Factory\n{\n    // A Type that defines the tags 👇\n    [Counter<int>(typeof(PricingPageTags), Name = \"myapp.products.pricing_page_requests\")]\n    internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);\n    // previously:\n    // [Counter<int>(\"product_id\", Name = \"myapp.products.pricing_page_requests\")]\n    // internal static partial PricingPageViewed CreatePricingPageViewed(Meter meter);\n}\n\npublic readonly struct PricingPageTags\n{\n    [TagName(\"product_id\")]\n    public required string ProductId { get; init; }\n    public required Environment Environment { get; init; }\n}\n\npublic enum Environment\n{\n    Development,\n    QA,\n    Production,\n}\n```\n\nSo we have two changes:\n\n- We're passing a\n`Type`\n\nin the`[Counter<T>]`\n\nattribute, instead of a list of tag arguments. - We've defined a struct type that includes all the tags we want to add to a value.\n- This is defined as a\n`readonly struct`\n\nto avoid additional allocations. - We specific the tag name for\n`ProductId`\n\n. By default,`Environment`\n\nuses the name`\"Environment\"`\n\n(which may not be what you want, but this is for demo reasons!). - We can only use\n`string`\n\nor`enum`\n\ntypes in the tags\n\n- This is defined as a\n\nThe source generator then does its thing, and so we need to update our API callsite to this:\n\n``` js\napp.MapGet(\"/product/{id}\", (int id, ProductMetrics metrics) =>\n{\n    metrics.PricingPageViewed.Add(1, new PricingPageTags()\n    {\n         ProductId = id.ToString(CultureInfo.InvariantCulture),\n         Environment = ProductMetrics.Environment.Production,\n    });\n    return $\"Details for product {id}\";\n});\n```\n\nIn the generated code we need to pass a `PricingPageTags`\n\nobject into the `Add()`\n\nmethod, instead of individually passing each tag value.\n\nNote that we had to pass a\n\n`string`\n\nfor`ProductId`\n\n, we can't use an`int`\n\nlike we were before. That's notgreatperf wise, but previously we were boxing the`int`\n\nto an`object?`\n\nsothatwasn't great either😅 Avoiding this allocation would be recommended if possible, but that's out of the scope for this post!\n\nAs before, let's take a look at the generated code.\n\n[Exploring the generated code](#exploring-the-generated-code-1)\n\nThe generated code in this case is almost identical to before. The only difference is in the generated `Add`\n\nmethod:\n\n```\ninternal sealed class PricingPageViewed\n{\n    private readonly Counter<int> _counter;\n\n    public PricingPageViewed(Counter<int> counter)\n    {\n        _counter = counter;\n    }\n\n    public void Add(int value, PricingPageTags o)\n    {\n        var tagList = new TagList\n        {\n            new KeyValuePair<string, object?>(\"product_id\", o.ProductId!),\n            new KeyValuePair<string, object?>(\"Environment\", o.Environment.ToString()),\n        };\n\n        _counter.Add(value, tagList);\n    }\n}\n```\n\nThis generated code is *almost* the same as before. The only difference is that it's \"splatting\" the `PricingPageTags`\n\nobject as individual tags in a `TagList`\n\n. So, does *this* mean the source generator is worth it?\n\n[Are the source generators worth using?](#are-the-source-generators-worth-using-)\n\nFrom my point of view, the strongly-typed tags scenario doesn't change any of the arguments I raised previously against the source generator. It's still mostly obfuscating otherwise simple APIs, not adding anything performance-wise as far as I can tell, and it still supports the \"`Instrument`\n\nin multiple `Meter`\n\nscenario\" that seems unlikely to be useful (to me, anyway).\n\nThe strongly-typed tags approach shown here, while nice, can just as easily be implemented manually. The generated code isn't really *adding* much. And in fact, given that it's calling `ToString()`\n\non an `enum`\n\n([which is known to be slow](/updates-to-netescapaades-enumgenerators-new-apis-and-system-memory-support/#why-should-you-use-an-enum-source-generator-)), the \"manual\" version can *likely* also provide better opportunities for performance optimizations.\n\nAbout the only argument I can see in favour of using the source generator is if you're using the \"`Instrument`\n\nin multiple `Meter`\n\n\" approach (let me know in the comments if you are, I feel like I'm missing something!). Or, I guess, if you just *like* the attribute-based generator approach and aren't worried about the points I raised. I'm a fan of source generators in general, but in this case, I don't think I would bother with them personally.\n\nOverall, the fact the generators don't really add much maybe just points to the *System.Diagnostics.Metrics* APIs being well defined? If you don't need much boilerplate to create the metrics, and you get the \"best performance\" by default, *without* needing a generator, then that seems like a *good* thing 😄\n\n[Summary](#summary)\n\nIn this post I showed how to use the source generators that ship in the [Microsoft.Extensions.Telemetry.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.Telemetry.Abstractions) to help generating metrics with the *System.Diagnostics.Metrics* APIs. I show how the source generator changes the way you define your metric, but fundamentally generates roughly the same code as [in my previous post](/creating-and-consuming-metrics-with-system-diagnostics-metrics-apis/). I then show how you can also create strongly-typed tags, which helps avoid a typical class of bugs.\n\nOverall, I didn't feel like the source generator saved much in the way of the code you write or provides performance benefits, unlike many other built-in source generators. The generated code caters to additional scenarios, such as registering the same `Instrument`\n\nwith multiple `Meter`\n\ns, but that seems like a niche scenario.", "url": "https://wpnews.pro/news/exploring-the-underwhelming-system-diagnostics-metrics-source-generators-system", "canonical_source": "https://andrewlock.net/creating-strongly-typed-metics-with-a-source-generator/", "published_at": "2026-02-03 10:00:00+00:00", "updated_at": "2026-05-23 21:38:48.177439+00:00", "lang": "en", "topics": ["developer-tools", "enterprise-software", "data"], "entities": ["Microsoft", ".NET", "System.Diagnostics.Metrics", "Microsoft.Extensions.Telemetry.Abstractions", "NuGet"], "alternates": {"html": "https://wpnews.pro/news/exploring-the-underwhelming-system-diagnostics-metrics-source-generators-system", "markdown": "https://wpnews.pro/news/exploring-the-underwhelming-system-diagnostics-metrics-source-generators-system.md", "text": "https://wpnews.pro/news/exploring-the-underwhelming-system-diagnostics-metrics-source-generators-system.txt", "jsonld": "https://wpnews.pro/news/exploring-the-underwhelming-system-diagnostics-metrics-source-generators-system.jsonld"}}