BrandGhost
OpenTelemetry Metrics .NET: Counters, Histograms, and IMeterFactory

OpenTelemetry Metrics .NET: Counters, Histograms, and IMeterFactory

OpenTelemetry metrics .NET gives you the aggregated numerical picture of your application's health over time. Where traces tell you about individual requests and logs capture discrete events, metrics answer questions like "how many orders per minute are we processing?" and "what is our P95 response time over the last hour?" If you want dashboards, alerting thresholds, and SLOs that hold up under production load, metrics are the foundation you build them on.

This guide covers the full picture of OpenTelemetry metrics .NET -- from the System.Diagnostics.Metrics types that .NET provides natively, through the different instrument types (counters, histograms, gauges, up-down counters), to wiring everything up with the OpenTelemetry SDK for export to Prometheus or an OTLP-compatible backend. By the end, you will have a complete working example and a clear mental model of when to reach for each instrument type.

If you're looking for the full picture, check out the OpenTelemetry in .NET: Complete Observability Guide.

Metrics vs Traces vs Logs -- Understanding the Differences

Before diving into the API, it is worth being clear on what metrics are not. Logs are discrete events -- something happened, here are the details. Traces are request-scoped timelines -- here is everything that happened during this one request, with timing. Metrics are aggregations -- here is a statistical summary of many events over a time window. They compress a high volume of raw data into a few numbers that are cheap to store and fast to query.

A histogram metric for request latency does not give you the exact duration of any particular request -- it gives you the distribution of durations across all requests in the last minute. From that distribution, you can derive P50, P95, and P99 latency, which is what you need for SLO tracking. Individual request traces answer "why was this one slow?" while metrics answer "are we generally slow?"

This is also why metrics are efficient enough to run on every request in production without sampling. A trace sampled at 1% misses 99% of requests; a counter incremented on every request catches them all. The two signals complement each other -- metrics for alerting and trending, traces for investigation.

Structured logging in .NET completes the picture. Logs give you the narrative. Metrics give you the numbers. Traces give you the timeline. Together, they are the three pillars of observability.

System.Diagnostics.Metrics -- The .NET-Native API

Just like tracing has System.Diagnostics.Activity, the System.Diagnostics.Metrics namespace provides the native .NET API for metrics. The central class is Meter -- a factory for creating individual metric instruments. OpenTelemetry wraps this API the same way it wraps ActivitySource for tracing: your code uses the native types, and OpenTelemetry listens and exports.

This design means a library that instruments itself with System.Diagnostics.Metrics does not need to take a dependency on the OpenTelemetry SDK. Applications hosting that library can choose their own observability stack -- OpenTelemetry, a custom MeterListener, or anything else -- without requiring changes to the library.

The Meter class is the entry point. You create instruments from it: counters, histograms, gauges, and up-down counters. Each instrument has a name, an optional unit, and an optional description. The name is what appears in your dashboards and alert rules.

IMeterFactory -- Inject It, Do Not New It

You can create a Meter directly with new Meter("MyApp.Orders"), but the recommended approach in application code is to use IMeterFactory injected through the DI container. This might seem like a minor distinction, but it has real practical benefits.

Testability -- When you inject IMeterFactory, tests can provide a real implementation (registered via services.AddMetrics()) and observe the metrics through MeterListener without requiring a full OpenTelemetry pipeline. This is the same argument for injecting any dependency rather than newing it up directly.

Lifetime management -- IMeterFactory manages the lifetime of the Meter instances it creates. When the factory is disposed at app shutdown, all meters it created are disposed too, giving you clean resource cleanup.

Consistency -- Using IMeterFactory is the pattern that AddMeter() in the OpenTelemetry pipeline is designed to work with. It fits naturally into .NET's DI ecosystem.

Note: IMeterFactory was introduced in .NET 8 (via Microsoft.Extensions.Diagnostics). On .NET 6/7, you can use new Meter(...) directly -- but you lose the DI lifecycle management and testability advantages described here.

This aligns directly with the Dependency Inversion Principle -- depend on the IMeterFactory abstraction rather than the concrete Meter constructor. The same principle applies throughout the SOLID design guidelines for well-structured .NET code. For the full picture of how AddSingleton, AddScoped, and AddTransient interact with these registrations, the IServiceCollection guide is worth reviewing.

Here is a complete example of a metrics class using IMeterFactory:

using System.Diagnostics.Metrics;

namespace MyApp.Services;

public class OrderMetrics : IDisposable
{
    private readonly Meter _meter;
    private readonly Counter<long> _ordersCreated;
    private readonly Histogram<double> _orderProcessingTime;
    private readonly ObservableGauge<int> _pendingOrders;
    private int _pendingOrderCount;

    public OrderMetrics(IMeterFactory meterFactory)
    {
        _meter = meterFactory.Create("MyApp.Orders");

        _ordersCreated = _meter.CreateCounter<long>(
            "orders.created",
            unit: "{orders}",
            description: "Total number of orders created");

        _orderProcessingTime = _meter.CreateHistogram<double>(
            "orders.processing_duration",
            unit: "ms",
            description: "Time to process an order in milliseconds");

        _pendingOrders = _meter.CreateObservableGauge<int>(
            "orders.pending",
            () => _pendingOrderCount,
            unit: "{orders}",
            description: "Current number of pending orders");
    }

    public void RecordOrderCreated(string region)
    {
        _ordersCreated.Add(1, new KeyValuePair<string, object?>("region", region));
    }

    public void RecordProcessingTime(double milliseconds, bool success)
    {
        _orderProcessingTime.Record(milliseconds,
            new KeyValuePair<string, object?>("success", success));
    }

    public void SetPendingOrderCount(int count) => _pendingOrderCount = count;

    public void Dispose() => _meter.Dispose();
}

This class encapsulates all the metric instruments for the orders domain. It is registered as a singleton in DI (since metric instruments are long-lived stateful objects), and it exposes semantic methods rather than exposing the raw instruments. Callers do not need to know about instrument types -- they call RecordOrderCreated() and the metrics class handles the rest.

OpenTelemetry Metrics Instrument Types in .NET

Counter

A Counter<T> is monotonically increasing -- it can only go up, and you can only call Add() with a non-negative value. It represents a cumulative total over time: requests processed, orders created, errors encountered, messages sent.

_ordersCreated.Add(1, new KeyValuePair<string, object?>("region", region));

You can attach tags (called dimensions or attributes in OpenTelemetry terminology) to every measurement. This lets you break down the counter by region, status, user type, HTTP method, or whatever categorical attribute matters for your analysis. Your observability backend aggregates these as separate time series so you can filter and group in dashboards.

The golden rule for tags: use low-cardinality values. Region names, HTTP status codes, and boolean flags are good tag values. User IDs, order IDs, and request GUIDs are not -- they create millions of unique time series and will cause serious performance problems in your metrics backend.

Histogram

A Histogram<T> records individual measurements and computes their distribution. Call Record() with each measurement value. The distribution is what makes histograms powerful -- from the distribution, your backend can compute any percentile at query time.

public async Task<OrderResult> ProcessOrderAsync(Order order)
{
    var stopwatch = System.Diagnostics.Stopwatch.StartNew();

    try
    {
        var result = await _orderProcessor.ProcessAsync(order);

        _metrics.RecordProcessingTime(
            stopwatch.Elapsed.TotalMilliseconds,
            success: result.IsSuccess);

        return result;
    }
    catch (Exception)
    {
        _metrics.RecordProcessingTime(
            stopwatch.Elapsed.TotalMilliseconds,
            success: false);
        throw;
    }
}

Use histograms for latencies, request sizes, queue depths at processing time -- anything where the distribution matters, not just the total. Recording duration in milliseconds and querying P95 latency is a far more actionable SLO metric than average latency. Averages can hide a small percentage of extremely slow requests; P95 and P99 percentiles surface them.

The success tag in the example above lets you compare the processing time distribution for successful requests vs. failed ones -- a dimension that can reveal a lot about where failures are happening in the processing pipeline.

ObservableGauge

An ObservableGauge<T> does not record individual measurements. Instead, it calls a callback function whenever the metrics system needs a current reading. Use it for values that represent a current state: queue depth, active connections, cache size, thread pool queue length, memory usage.

_pendingOrders = _meter.CreateObservableGauge<int>(
    "orders.pending",
    () => _pendingOrderCount,
    unit: "{orders}",
    description: "Current number of pending orders");

The callback is invoked by the metrics pipeline on its collection interval -- not on every request. Your code does not push the value; the pipeline pulls it when it needs it. This means the callback should be fast and non-blocking. It should read from memory (a field, a cached value, an atomic integer) rather than making database calls or I/O.

Observable instruments are the right tool for runtime health indicators: active connections, pending work items, memory pressure, cache hit counts. They are not the right tool for request-level measurements, which belong in counters and histograms.

UpDownCounter

An UpDownCounter<T> is like a counter but can go in both directions. Pass a positive value to Add() to increment; pass a negative value to decrement. Use it for tracking the current number of something in-flight: active requests, items being processed, concurrent connections.

public class RequestTrackingMiddleware : IMiddleware
{
    private readonly UpDownCounter<long> _activeRequests;

    public RequestTrackingMiddleware(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("MyApp.Http");
        _activeRequests = meter.CreateUpDownCounter<long>(
            "http.server.active_requests",
            unit: "{requests}",
            description: "Number of currently active HTTP requests");
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        _activeRequests.Add(1,
            new KeyValuePair<string, object?>("method", context.Request.Method));
        try
        {
            await next(context);
        }
        finally
        {
            _activeRequests.Add(-1,
                new KeyValuePair<string, object?>("method", context.Request.Method));
        }
    }
}

This ASP.NET Core middleware increments the counter when a request arrives and decrements it when the request completes -- in the finally block so it correctly handles exceptions and cancellations. You can see at any point how many requests are actively being processed, broken down by HTTP method.

Registering the OpenTelemetry Metrics Pipeline in .NET

With your OrderMetrics class defined, you need two things: register it in DI, and tell OpenTelemetry to listen to its meter.

using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<OrderMetrics>();

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService("MyApp", serviceVersion: "1.0.0"))
    .WithMetrics(metrics => metrics
        .AddMeter("MyApp.Orders")
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddPrometheusExporter()); // or AddOtlpExporter()

var app = builder.Build();
app.MapPrometheusScrapingEndpoint(); // exposes /metrics endpoint

AddMeter("MyApp.Orders") is the key registration call. It tells OpenTelemetry to subscribe to measurements from any Meter with that name, regardless of how the Meter was created. This is the same source-registration pattern as AddSource() in tracing -- you opt in to each source explicitly.

AddAspNetCoreInstrumentation() automatically provides metrics for incoming HTTP requests: request counts, durations, and active request counts. AddRuntimeInstrumentation() gives you GC metrics, thread pool stats, and memory pressure -- all the runtime health indicators you typically want on a baseline dashboard. These two lines alone give you a substantial set of useful metrics without any custom instrumentation.

This kind of service registration follows the same ASP.NET Core Web API startup patterns you already use for the rest of your application configuration.

Prometheus vs OTLP

AddPrometheusExporter() combined with MapPrometheusScrapingEndpoint() exposes a /metrics endpoint in Prometheus text format. Prometheus scrapes this endpoint on its configured interval. This is a pull-based model and works well if you are already running a Prometheus stack.

AddOtlpExporter() pushes metrics to an OTLP receiver (the OpenTelemetry Collector, Grafana Agent, etc.) on a configured push interval. This is the more portable choice -- OTLP is the OpenTelemetry standard protocol, and most modern backends support it directly.

You can use both exporters simultaneously if you are running both Prometheus and an OTLP pipeline. The SDK fans out to all registered exporters independently.

Naming Conventions for Metric Instruments

OpenTelemetry defines semantic conventions for instrument names. Following them makes your metrics compatible with standard dashboards and alert rules from the community. The conventions use lowercase, dot-separated names:

  • http.server.request.duration -- histogram for HTTP server request durations
  • http.server.active_requests -- up-down counter for active requests
  • db.client.operation.duration -- histogram for database operation durations
  • messaging.publish.duration -- histogram for message publishing durations

For your own application metrics, use your service name as a prefix followed by the domain and instrument name: myapp.orders.created, myapp.payments.processing_duration, myapp.cache.hit_ratio.

The unit parameter follows the Unified Code for Units of Measure (UCUM): ms for milliseconds, s for seconds, By for bytes, {requests} or {orders} for dimensionless counts of named things. Getting units right means your tooling can format values correctly and your team can read dashboards without second-guessing what scale the numbers are in.

Testing Metrics with IMeterFactory

One of the concrete benefits of IMeterFactory over new Meter() is testability. You do not need a running OpenTelemetry pipeline to verify that your metrics class is recording the right values.

// In unit tests -- use the real IMeterFactory from DI
[Fact]
public void RecordOrderCreated_IncrementsCounter()
{
    var services = new ServiceCollection();
    services.AddMetrics(); // registers IMeterFactory
    services.AddSingleton<OrderMetrics>();

    var provider = services.BuildServiceProvider();
    var metrics = provider.GetRequiredService<OrderMetrics>();

    metrics.RecordOrderCreated("us-east");

    // IMeterFactory-based metrics are testable via MeterListener
    // The key benefit is that you CAN observe them without coupling to a specific exporter
}

For full assertion of recorded values, you pair this with MeterListener to subscribe to measurements from the test. This lets you verify that a specific instrument emits the right value with the right tags without exporting to any backend. It is particularly valuable in integration tests where you want to confirm instrumentation fires correctly as part of feature verification -- not just that the feature works, but that the metrics it is supposed to emit actually fire.

The IMeterFactory approach also means you can create isolated metrics instances per test without global state leaking between tests -- a problem you would encounter with static meters where state persists across the test suite.

Performance Considerations

A common concern with instrumentation is overhead. The System.Diagnostics.Metrics API is designed to be low-overhead. When no listener is subscribed to a meter, Add() and Record() calls are near-zero cost -- the runtime detects the absence of listeners and short-circuits. Even with listeners active, the hot path for counters and histograms is designed to avoid unnecessary allocations.

If you are using BenchmarkDotNet to measure the performance of your own code, you would want to exclude OpenTelemetry instrumentation from benchmarks -- the SDK does have real overhead when listeners are active, and that overhead would show up in your measurements. For production application code where you are not benchmarking the instrumentation itself, the overhead is generally acceptable relative to the value of the observability data you gain.

Histogram cardinality is the area where it is possible to create performance problems. Each unique combination of tag values creates a separate time series in your metrics backend. Keep tag cardinality low -- use categorical labels with a bounded set of values rather than per-request identifiers.

Frequently Asked Questions

What is the difference between Counter and UpDownCounter in OpenTelemetry metrics .NET?

A Counter<T> is monotonically increasing -- it can only go up, and Add() accepts only non-negative values. It represents a cumulative total, like total requests served or total errors encountered. An UpDownCounter<T> can increase or decrease -- you pass positive or negative values to Add(). Use it for values representing a current quantity that fluctuates, such as active connections, items currently in a processing queue, or concurrent operations in flight.

When should I use Histogram instead of Counter for OpenTelemetry metrics .NET?

Use a histogram when the distribution of values matters, not just the total count. Request latency is the canonical example -- you do not just want to know that 10,000 requests completed, you want to know the P50/P95/P99 of their durations. Response sizes, queue wait times, and retry counts are also good histogram candidates. Use a counter when you need cumulative totals where only the count matters, not the distribution of individual values.

Why use IMeterFactory instead of creating a Meter with new Meter()?

IMeterFactory is the recommended approach in application code for three reasons. First, it integrates with the DI container's lifetime management -- the factory disposes meters it creates when the application shuts down, giving you clean resource cleanup. Second, it is testable -- you can inject IMeterFactory from services.AddMetrics() in tests and observe measurements via MeterListener without a full pipeline. Third, it follows the dependency injection patterns that .NET's ecosystem is built around. Direct new Meter() usage remains appropriate in library code where DI is not available.

How do I export OpenTelemetry metrics .NET to Prometheus?

Add the OpenTelemetry.Exporter.Prometheus.AspNetCore NuGet package. In your Program.cs, call .AddPrometheusExporter() on your metrics builder. Then call app.MapPrometheusScrapingEndpoint() after building the app to expose the /metrics endpoint. Prometheus scrapes this endpoint on its configured interval. You also need AddMeter("YourMeterName") in the metrics pipeline to subscribe to your custom meters -- without that registration, custom measurements are not collected.

Can I add multiple tags to a single metric measurement?

Yes. Each Add() or Record() call accepts multiple KeyValuePair<string, object?> values. You can pass several tags per measurement to capture multiple dimensions. The cardinality of tags is the key constraint -- avoid using high-cardinality values (user IDs, order IDs, request GUIDs) as tag values directly. Stick to low-cardinality categorical values like region, environment, HTTP status code, and boolean flags. High-cardinality tags can create performance problems in your metrics backend.

How do ObservableGauge callbacks work in .NET metrics?

The ObservableGauge<T> callback is invoked by the metrics pipeline during its collection cycle, not on every request. The collection interval is configured by the exporter -- for Prometheus it is the scrape interval, for OTLP it is the push interval. Your callback should be fast and should not block. It should read a value that is already in memory (a cached count, an atomic integer, a field on your service) rather than making a database call or external I/O. For values that require external retrieval, consider updating a backing field on a scheduled basis and having the gauge callback read that field.

How do I ensure my OpenTelemetry metrics .NET instrument names follow conventions?

Use lowercase, dot-separated names following the semantic conventions at opentelemetry.io. Prefix your custom metrics with your service or library name: myapp.orders.processing_duration. Use UCUM units: ms for milliseconds, s for seconds, By for bytes, {requests} for dimensionless counts. Check the OpenTelemetry semantic conventions registry for your domain -- HTTP, database, messaging, and runtime metrics all have documented standard names. Following conventions makes your metrics compatible with community dashboards and reduces the cognitive overhead for new team members reading your dashboards.

Wrapping Up

OpenTelemetry metrics .NET are built on a well-designed foundation. The System.Diagnostics.Metrics namespace gives you portable, low-overhead instrument types that work with any listener. IMeterFactory brings metrics into the DI world where they belong in application code. The OpenTelemetry SDK wires everything to your exporter of choice with a handful of registration calls.

The instrument type selection is worth thinking through carefully: counters for cumulative totals, histograms for distributions and percentiles, observable gauges for current-state snapshots, up-down counters for quantities that fluctuate. Getting that right from the start means your dashboards and alerts will be built on data that actually answers the questions you care about in production.

Start with AddRuntimeInstrumentation() and AddAspNetCoreInstrumentation() for a useful baseline without writing any custom code. Then add your own application-level instruments as you identify what needs to be tracked. Metrics are most valuable when they reflect your business domain -- orders, payments, users, messages -- not just the underlying technical infrastructure.


Disclaimer: Yes! This was written by AI based on input and direction from yours truly, Nick Cosentino, for Dev Leader. I have ensured that it represents my thoughts and perspectives on OpenTelemetry metrics in .NET using IMeterFactory, Counters, Histograms, and the metrics pipeline.

OpenTelemetry in .NET: Complete Observability Guide

The complete OpenTelemetry .NET guide for developers: traces, metrics, and logs with C# examples, ASP.NET Core setup, exporters, and distributed tracing.

OpenTelemetry Traces .NET -- ActivitySource, Spans, and the Tracing Pipeline

Learn how OpenTelemetry traces .NET applications use: ActivitySource, Activity, and the tracing pipeline -- with C# examples for ASP.NET Core.

HttpClient Logging and Observability in .NET 10: ILogger, DelegatingHandler, and OpenTelemetry

How to implement HttpClient logging and observability in .NET 10 -- covering built-in ILogger integration, DelegatingHandler for custom request logging, OpenTelemetry tracing and metrics, and redacting sensitive headers in production.

An error has occurred. This application may no longer respond until reloaded. Reload