OpenTelemetry is the vendor-neutral standard for collecting logs, metrics and distributed traces, and in .NET it builds directly on platform APIs you already use: ILogger, Meter and ActivitySource. This guide is for .NET developers who want production-grade observability without vendor lock-in. It explains how OpenTelemetry in .NET works, how to instrument ASP.NET Core with the 1.19 SDK, how to write custom spans and metrics, how context propagation and sampling behave, which backends to export to over OTLP, and how to use the Aspire dashboard as a local viewer.
What Is OpenTelemetry?#
OpenTelemetry (OTel) is a CNCF project that defines a specification, language SDKs, a wire protocol called OTLP, a standalone Collector, and semantic conventions, the shared attribute names that make telemetry from different services and vendors look the same. You instrument code once, and a configuration change decides whether the data lands in Azure Monitor, Grafana, Jaeger, Prometheus or anything else that speaks OTLP.
The .NET implementation is unusual in a useful way. Other languages ship an OpenTelemetry API that library authors call, but .NET already had equivalent APIs in the base class library: System.Diagnostics.ActivitySource for tracing, System.Diagnostics.Metrics.Meter for metrics and Microsoft.Extensions.Logging for logs. ASP.NET Core, HttpClient, gRPC, EF Core and the Azure SDKs emit telemetry through these APIs with no OpenTelemetry dependency, and the cost is close to zero when nothing listens. The OpenTelemetry SDK is simply the listener that samples, processes and exports that data.
The SDK is mature. OpenTelemetry .NET 1.19.1 shipped in September 2026, all three signals are stable, and it supports every supported .NET version plus .NET Framework, except 3.5. Core packages live in the opentelemetry-dotnet repository; most instrumentation libraries live in opentelemetry-dotnet-contrib, where some, such as the EF Core, gRPC client and Redis instrumentations, are still prerelease.
How OpenTelemetry Works in .NET#
Every signal follows the same pipeline: your code and libraries emit data through a .NET API, an SDK provider subscribes to it, processors sample, enrich and batch it, and an exporter sends it, usually over OTLP to a Collector or a backend. A resource attached to every record identifies the emitting service with attributes such as service.name and service.version.
| Signal | .NET API you write against | OpenTelemetry SDK component | Typical backends |
|---|---|---|---|
| Traces | ActivitySource, Activity | TracerProvider, samplers, span processors | Tempo, Jaeger, Azure Monitor |
| Metrics | Meter, Counter<T>, Histogram<T>, Gauge<T> | MeterProvider, views, metric readers | Prometheus, Mimir, Azure Monitor |
| Logs | ILogger, [LoggerMessage] | LoggerProvider, log processors | Loki, Azure Monitor |
The three signals answer different questions. Metrics are cheap aggregates that tell you that something is wrong: error rates, latency percentiles, queue depth. Traces show where time went in a single request across services. Logs carry the detailed why. When a log is written inside an active span, the SDK stamps it with the trace and span IDs, so you can jump from a slow trace straight to its logs.
Getting Started: Instrument an ASP.NET Core App#
Add the hosting extensions, the OTLP exporter and the instrumentation packages for the libraries you use:
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.RuntimeThen register all three signals in Program.cs. UseOtlpExporter wires one OTLP exporter to logs, metrics and traces, and reads the endpoint and protocol from the standard OTEL_EXPORTER_OTLP_* environment variables.
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true; // keep the rendered message next to the template
logging.IncludeScopes = true; // export BeginScope values as attributes
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName: "checkout-api", serviceVersion: "2.3.0"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource(CheckoutService.SourceName))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(CheckoutMetrics.MeterName))
.UseOtlpExporter(); // OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL
builder.Services.AddSingleton<CheckoutMetrics>();
builder.Services.AddScoped<CheckoutService>();
var app = builder.Build();
app.Run();Two details save debugging time. The OTLP exporter defaults to gRPC on port 4317; set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf to use port 4318 instead. UseOtlpExporter can be called only once and cannot be combined with the signal-specific AddOtlpExporter methods; mixing them throws a NotSupportedException. If you use .NET Aspire, the generated ServiceDefaults project contains this same wiring, and the AppHost sets the OTLP variables for every project.
Distributed Tracing with ActivitySource and Activity#
A trace is a tree of spans, and in .NET a span is an Activity. Create one ActivitySource per component, keep it in a static field, and register its name with AddSource, which also accepts wildcards such as Contoso.*. StartActivity returns null when no listener is interested, so always use the null-conditional operator. Choose an ActivityKind that describes the span's role: Server and Client for RPC, Producer and Consumer for messaging, and Internal for work inside a process.
Name spans after the operation, not the data: checkout.process groups well in every backend, while checkout 81723 creates a new span name per order. Put identifiers in tags instead. On failure, set the status to Error and attach the exception. Activity.AddException, added in .NET 9, records it as a span event following the OpenTelemetry conventions, and .NET 9 also added Activity.AddLink for linking spans after creation, which is useful for batch consumers.
using System.Diagnostics;
public sealed class CheckoutService(PaymentClient payments, ILogger<CheckoutService> logger)
{
public const string SourceName = "Contoso.Checkout";
private static readonly ActivitySource Source = new(SourceName, "2.3.0");
public async Task<Receipt> CheckoutAsync(Cart cart, CancellationToken ct)
{
using var activity = Source.StartActivity("checkout.process", ActivityKind.Internal);
activity?.SetTag("checkout.cart.id", cart.Id);
activity?.SetTag("checkout.cart.item_count", cart.Items.Count);
try
{
// The HttpClient span becomes a child of this one automatically
var receipt = await payments.ChargeAsync(cart, ct);
activity?.AddEvent(new ActivityEvent("payment.authorized"));
return receipt;
}
catch (PaymentDeclinedException ex)
{
activity?.SetStatus(ActivityStatusCode.Error, "payment declined");
activity?.AddException(ex);
logger.LogWarning(ex, "Payment declined for cart {CartId}", cart.Id);
throw;
}
}
}Custom Metrics with Meter, Counters and Histograms#
Pick the instrument by the question you want to answer. A Counter<T> only goes up, for example orders placed. An UpDownCounter<T> tracks values that rise and fall, such as active checkouts. A Histogram<T> records distributions, such as latency or order value, from which backends compute percentiles. Gauge<T>, added in .NET 9, records the current value of something non-additive, and observable instruments are read by callback at each collection cycle.
Since .NET 8, hosts register IMeterFactory in dependency injection. Use it instead of static Meter instances so meters stay isolated per service provider, which makes metrics testable with MetricCollector<T> from Microsoft.Extensions.Diagnostics.Testing. The .NET guidance is to use lowercase, dotted names with underscores inside words, UCUM-style units such as s or {order}, and bounded tags. Keep each instrument under roughly a thousand tag combinations, and histograms far below that. Never tag metrics with user IDs, order IDs or URLs with IDs in them.
using System.Diagnostics;
using System.Diagnostics.Metrics;
public sealed class CheckoutMetrics
{
public const string MeterName = "Contoso.Checkout";
private readonly Counter<long> _orders;
private readonly Histogram<double> _orderValue;
private readonly UpDownCounter<int> _activeCheckouts;
public CheckoutMetrics(IMeterFactory meterFactory, IInventoryCache cache)
{
var meter = meterFactory.Create(MeterName);
_orders = meter.CreateCounter<long>("checkout.orders", unit: "{order}",
description: "Completed orders");
// InstrumentAdvice (DiagnosticSource 9+) suggests bucket boundaries to the SDK
_orderValue = meter.CreateHistogram<double>("checkout.order.value", unit: "USD",
description: "Order value", tags: null,
advice: new InstrumentAdvice<double> { HistogramBucketBoundaries = [10, 50, 100, 500] });
_activeCheckouts = meter.CreateUpDownCounter<int>("checkout.active", unit: "{checkout}");
// Read on each collection cycle instead of on every change
meter.CreateObservableGauge("checkout.inventory_cache.size", () => cache.Count,
unit: "{item}");
}
public void CheckoutStarted() => _activeCheckouts.Add(1);
public void CheckoutFinished(double value, string paymentMethod)
{
var tags = new TagList { { "checkout.payment.method", paymentMethod } }; // bounded set
_activeCheckouts.Add(-1);
_orders.Add(1, tags);
_orderValue.Record(value, tags);
}
}You get a lot without writing code. Since .NET 8, ASP.NET Core publishes built-in metrics through the Microsoft.AspNetCore.Hosting and Microsoft.AspNetCore.Server.Kestrel meters, HttpClient publishes through System.Net.Http, and .NET 9 added the System.Runtime meter for GC, JIT, thread pool and exception metrics. On .NET 9 and later, AddRuntimeInstrumentation simply subscribes to that built-in meter.
Logs: Connecting ILogger to OpenTelemetry#
OpenTelemetry .NET does not add a logging API: ILogger is the API, and the OpenTelemetry logger provider exports what it receives. That means your existing logging configuration still applies. Filter the exported categories under the Logging:OpenTelemetry:LogLevel configuration section, independently of the console provider.
Write structured logs with message templates, never string interpolation, so backends can index the parameters. The [LoggerMessage] source generator produces allocation-free logging methods and gives each event a stable ID. Scopes attach context such as a tenant ID to every log in a block when IncludeScopes is enabled.
public static partial class CheckoutLog
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information,
Message = "Order {OrderId} completed for {Amount} {Currency}")]
public static partial void OrderCompleted(
ILogger logger, string orderId, decimal amount, string currency);
[LoggerMessage(EventId = 1002, Level = LogLevel.Warning,
Message = "Inventory reservation for {Sku} failed after {Attempts} attempts")]
public static partial void ReservationFailed(ILogger logger, string sku, int attempts);
}
// Inside a request, TraceId and SpanId are attached automatically
using (logger.BeginScope(new Dictionary<string, object> { ["tenant.id"] = tenantId }))
{
CheckoutLog.OrderCompleted(logger, order.Id, order.Total, "USD");
}Context Propagation Across Services and Message Queues#
A distributed trace survives process boundaries because each hop forwards its context. OpenTelemetry .NET uses the W3C Trace Context headers traceparent and tracestate plus the W3C baggage header by default. For HTTP and gRPC you do nothing: HttpClient injects the headers and ASP.NET Core extracts them, so the server span becomes a child of the client span.
Message brokers are different, because the message may be consumed minutes later by another process. If your messaging library is not instrumented, inject the context into message headers when publishing and extract it when consuming. Treat baggage with care: it travels to every downstream service, including third-party APIs, so never put secrets or personal data in it.
using System.Diagnostics;
using OpenTelemetry;
using OpenTelemetry.Context.Propagation;
public static class MessagingTelemetry
{
public const string SourceName = "Contoso.Messaging";
private static readonly ActivitySource Source = new(SourceName);
private static readonly TextMapPropagator Propagator = Propagators.DefaultTextMapPropagator;
// Producer: start a span and copy traceparent, tracestate and baggage into headers
public static Activity? StartPublish(string queue, IDictionary<string, string> headers)
{
var activity = Source.StartActivity($"{queue} publish", ActivityKind.Producer);
var context = activity?.Context ?? Activity.Current?.Context ?? default;
Propagator.Inject(new PropagationContext(context, Baggage.Current), headers,
static (carrier, key, value) => carrier[key] = value);
return activity;
}
// Consumer: continue the producer's trace from the headers
public static Activity? StartProcess(string queue, IReadOnlyDictionary<string, string> headers)
{
var parent = Propagator.Extract(default, headers,
static (carrier, key) => carrier.TryGetValue(key, out var value)
? new[] { value }
: Array.Empty<string>());
Baggage.Current = parent.Baggage;
return Source.StartActivity($"{queue} process", ActivityKind.Consumer,
parent.ActivityContext);
}
}Sampling Strategies for Distributed Traces#
Traces are the most expensive signal, so production systems sample them. The SDK's default sampler is ParentBased(AlwaysOn), which keeps every trace. Head sampling decides when a trace starts: ParentBased(TraceIdRatioBased(0.1)) keeps 10% of new traces, and because every downstream service honors the parent's decision, you get complete traces rather than fragments. You can also set it without code through OTEL_TRACES_SAMPLER=parentbased_traceidratio and OTEL_TRACES_SAMPLER_ARG=0.1.
Head sampling cannot know whether a request will fail. Tail sampling in the OpenTelemetry Collector waits until a trace is complete, then keeps all errors and slow requests plus a baseline percentage. It is stateful, so every span of a trace must reach the same Collector instance. Managed backends add their own strategies: the Azure Monitor distro uses rate-limited sampling of up to five traces per second by default.
Do not sample metrics. They are aggregated in-process, so they stay accurate and cheap at any traffic level. Use metrics for rates and SLOs, traces for examples, and exemplars to link the two. The SDK also lets you drop noisy spans at the source and reshape metrics with views:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
// Keep 10% of new traces and always follow the caller's decision
.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.10)))
.AddAspNetCoreInstrumentation(options =>
options.Filter = context => !context.Request.Path.StartsWithSegments("/health")))
.WithMetrics(metrics => metrics
// Custom latency buckets for one instrument
.AddView("checkout.payment.duration",
new ExplicitBucketHistogramConfiguration { Boundaries = [0.05, 0.1, 0.25, 0.5, 1, 2.5] })
// Drop a debugging instrument everywhere
.AddView(instrument => instrument.Name.StartsWith("checkout.debug.")
? MetricStreamConfiguration.Drop
: null));Semantic Conventions and Resource Attributes#
Semantic conventions are what make telemetry portable. The ASP.NET Core and HttpClient instrumentations follow the stable HTTP conventions, so spans carry attributes such as http.request.method, http.response.status_code, http.route and url.path, and request latency arrives as the http.server.request.duration histogram in seconds. Dashboards built for these names work for any language. For your own attributes, use a namespace you own, such as checkout.cart.id, and never reuse a standard name with a different meaning. .NET 10 adds support for declaring a telemetry schema URL on ActivitySource and Meter through the new ActivitySourceOptions.
Resource attributes identify the source. At minimum set service.name and service.version, either with AddService or with the OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES environment variables, which is convenient in containers.
The conventions now extend to generative AI. In Microsoft.Extensions.AI, UseOpenTelemetry wraps any IChatClient in a client that emits spans and metrics following the GenAI conventions, such as the gen_ai.client.token.usage and gen_ai.client.operation.duration metrics. See LLM observability and cost control for dashboards built on them.
using Microsoft.Extensions.AI;
builder.Services.AddChatClient(services => innerChatClient)
.UseOpenTelemetry(sourceName: "Contoso.Assistant", configure: client =>
client.EnableSensitiveData = false); // keep prompts and completions out of telemetry
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("Contoso.Assistant"))
.WithMetrics(metrics => metrics.AddMeter("Contoso.Assistant"));Exporting Telemetry: OTLP, the Collector and Backends#
Standardize on OTLP. Backends that once needed dedicated exporters now ingest OTLP natively: the Jaeger exporter was deprecated years ago, and the Zipkin exporter is deprecated and stops receiving updates in December 2026. In production, send telemetry to an OpenTelemetry Collector rather than straight to a vendor. The Collector batches and retries, applies tail sampling, scrubs sensitive attributes and routes each signal to the right backend, so services stay unaware of vendors. Grafana Alloy is a Collector distribution if you run the Grafana stack.
| Backend | Signals | How .NET sends data | Best fit |
|---|---|---|---|
| Aspire dashboard | Logs, metrics, traces | OTLP over gRPC or HTTP | Local development and short-term diagnostics |
| Azure Monitor (Application Insights) | Logs, metrics, traces | Azure Monitor OpenTelemetry distro | Azure-hosted workloads needing managed APM |
| Grafana stack (Tempo, Loki, Prometheus or Mimir) | All three | OTLP to Alloy or a Collector | Open-source observability, self-hosted or managed |
| Jaeger | Traces | OTLP on ports 4317 and 4318 | Trace search and analysis |
| Prometheus | Metrics | OTLP receiver, or a scrape endpoint | Metrics, alerting and SLOs |
For Prometheus, either start the server with --web.enable-otlp-receiver and push OTLP to it, or expose a scrape endpoint with the OpenTelemetry.Exporter.Prometheus.AspNetCore package, which is still in beta. A typical Collector configuration receives OTLP, tail-samples traces and fans out:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-requests
type: latency
latency: { threshold_ms: 1000 }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 10 }
exporters:
# Older Collector releases name these exporters otlp and otlphttp
otlp_grpc/tempo:
endpoint: tempo:4317
tls:
insecure: true
otlp_http/prometheus:
endpoint: http://prometheus:9090/api/v1/otlp
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlp_grpc/tempo]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlp_http/prometheus]On Azure, the Azure.Monitor.OpenTelemetry.AspNetCore distro configures all three signals plus ASP.NET Core, HttpClient and SQL client instrumentation in one call. It reads the connection string from APPLICATIONINSIGHTS_CONNECTION_STRING and supports Microsoft Entra authentication, so you can disable instrumentation-key ingestion.
using Azure.Identity;
using Azure.Monitor.OpenTelemetry.AspNetCore;
builder.Services.AddOpenTelemetry()
.UseAzureMonitor(options =>
{
options.Credential = new DefaultAzureCredential(); // Entra ID instead of keys
options.TracesPerSecond = 10; // default rate limit is 5
})
.WithTracing(tracing => tracing.AddSource(CheckoutService.SourceName))
.WithMetrics(metrics => metrics.AddMeter(CheckoutMetrics.MeterName));Local Development with the Aspire Dashboard#
You do not need a backend to see telemetry on your machine. The Aspire dashboard runs standalone as an OTLP viewer with pages for structured logs, traces and metrics, and it works for any OTLP-emitting app, even without an Aspire AppHost. Start it with the Aspire CLI or the container image, then point your app at it:
# Option 1: Aspire CLI (UI on 18888, OTLP on 4317 for gRPC and 4318 for HTTP)
aspire dashboard run
# Option 2: container image, mapping the container's OTLP ports to the standard ones
docker run --rm -it -d -p 18888:18888 -p 4317:18889 -p 4318:18890 \
--name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest
# Point the app at it; the login token is printed in the dashboard's output
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_SERVICE_NAME=checkout-api
dotnet runThe dashboard is protected by a browser login token by default. It keeps telemetry in memory only, dropping it when limits are reached and losing it on restart, so treat it as a development and short-term diagnostic tool rather than a production backend.
Best Practices#
- Name every service. Set
service.name,service.versionand an environment attribute on each service, or cross-service views become useless. - Instrument libraries with BCL APIs. Libraries should depend only on
ActivitySource,MeterandILogger; only applications reference the OpenTelemetry SDK and exporters. - Keep cardinality low. Span names and metric tags must come from small, fixed sets. Put IDs in span attributes and logs.
- Record failures consistently. Set error status, attach the exception once at the boundary that handles it, and avoid logging the same exception at every layer.
- Sample traces, not metrics. Use parent-based head sampling in services and tail sampling in the Collector to keep every error.
- Filter noise at the source. Exclude health probes and static files from request tracing.
- Protect sensitive data. Instrumentations redact URL query strings by default; keep it that way, keep personal data out of baggage and tags, and scrub attributes in the Collector.
- Test your telemetry. Assert on metrics with
MetricCollector<T>and on logs withFakeLogger, so refactoring does not silently break dashboards.
Common Pitfalls#
- Forgetting
AddSourceorAddMeter. Custom telemetry is silently dropped unless the provider subscribes to your source and meter names. - Creating sources per request.
ActivitySourceandMeterinstances are long-lived; create them once and dispose eachActivitywithusing. - High-cardinality tags. A user ID on a metric can multiply storage costs by orders of magnitude.
- Protocol and port mismatches. gRPC uses 4317 and HTTP uses 4318; sending one protocol to the other port fails quietly.
- Mixing exporter registration styles.
UseOtlpExporterplusAddOtlpExporterthrows at startup. - Non-parent-based sampling. Independent sampling decisions in each service produce broken, partial traces.
- Duplicate pipelines. Running a legacy vendor SDK alongside OpenTelemetry for the same signals doubles cost and confuses dashboards.
- Capturing prompts by accident. GenAI telemetry can include prompts and completions when sensitive data capture is on; decide explicitly and document retention.
Frequently Asked Questions#
Do my libraries need to reference OpenTelemetry packages?#
No. Libraries should emit telemetry through ActivitySource, Meter and ILogger, which are part of .NET. Applications add the OpenTelemetry SDK, subscribe to those sources and meters, and choose exporters. This keeps libraries lightweight and lets consumers use any backend.
What is the difference between OpenTelemetry and Application Insights?#
OpenTelemetry is the instrumentation standard and SDK; Application Insights, part of Azure Monitor, is a backend that stores and analyzes telemetry. Microsoft ships the Azure Monitor OpenTelemetry distro, which uses OpenTelemetry to collect logs, metrics and traces and send them to Application Insights. Your instrumentation stays portable if you later add or change backends.
Should services export directly to a backend or through a Collector?#
Direct export is fine for local development and small systems. In production, a Collector gives you batching, retries, tail sampling, attribute scrubbing and routing without redeploying services. It also isolates services from vendor-specific exporters and credentials.
How can I see OpenTelemetry data locally?#
Run the standalone Aspire dashboard with aspire dashboard run or its container image, and set OTEL_EXPORTER_OTLP_ENDPOINT to http://localhost:4317. It shows structured logs, traces and metrics for any OTLP-emitting app. For quick checks in tests or console apps, the console exporter prints telemetry to standard output.
Does OpenTelemetry slow down my application?#
The overhead is small when you follow the defaults: activities are not created when nothing listens, metrics are pre-aggregated in memory, and exporters batch in the background. Costs grow with span volume and tag cardinality, so sample traces in high-traffic services and keep tags bounded. Measure with your own load tests rather than relying on generic numbers.
Summary#
- .NET implements OpenTelemetry on platform APIs:
ActivitySourcefor traces,Meterfor metrics andILoggerfor logs, with the SDK acting as listener and exporter. - Configure all signals with
AddOpenTelemetry, set a resource withservice.name, and export over OTLP withUseOtlpExporter. - Write low-cardinality spans and metrics, use
IMeterFactory, and correlate logs with traces automatically. - Propagate W3C trace context over messaging, sample traces with parent-based head sampling plus Collector tail sampling, and never sample metrics.
- Use the Aspire dashboard locally and Azure Monitor, the Grafana stack, Jaeger or Prometheus in production, ideally behind a Collector.