OpenTelemetry
Wire traces, metrics and logs through the OpenTelemetry SDK and ship them over OTLP to any backend that speaks it.
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddAspNetCoreInstrumentation().AddOtlpExporter());OpenTelemetry is an open standard for producing and shipping telemetry. It defines names for common measurements, a wire protocol called OTLP, and a plug-in point for exporters. It is not a monitoring product; it is the thing that sends your data to one.
Why .NET's version looks thin
In most languages OpenTelemetry supplies the recording APIs too. In .NET the recording APIs are already in the base libraries: ILogger for logs, Meter for metrics, ActivitySource for spans. The SDK only collects what those produce and exports it. That is why instrumenting a library needs no OpenTelemetry dependency at all, and only the application chooses an exporter.
The three pipelines
AddOpenTelemetry() returns a builder. WithTracing and WithMetrics configure spans and metrics; logs are configured on builder.Logging instead. Each pipeline needs to be told what to collect, and nothing is collected by default:
AddSource(name)enables oneActivitySource;AddMeter(name)enables oneMeter.- An instrumentation package, such as
OpenTelemetry.Instrumentation.AspNetCore, registers the framework's own sources and meters for you and adds some extra tags. ConfigureResourcesets the attributes describing the process, of whichservice.nameis the one every backend groups by.
Why one standard is worth it
Each vendor used to ship its own SDK, so changing backend meant rewriting instrumentation. With OTLP the code emits one format and the choice of destination is configuration. Locally you can point it at the Aspire dashboard container and see the same traces you will see in production.
Shorthand
The chained tracing.AddAspNetCoreInstrumentation().AddOtlpExporter() is the same as two statements against tracing; each call returns the builder it was given.
Example
// Packages: OpenTelemetry.Extensions.Hosting,
// OpenTelemetry.Exporter.OpenTelemetryProtocol,
// OpenTelemetry.Instrumentation.AspNetCore,
// OpenTelemetry.Instrumentation.Http
using System.Diagnostics;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// The app's own span source. Its name is registered with AddSource below.
var source = new ActivitySource("Shop.Orders");
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.AddOtlpExporter();
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("shop-orders"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource(source.Name)
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddMeter("Microsoft.AspNetCore.Hosting")
.AddMeter("Microsoft.AspNetCore.Server.Kestrel")
.AddOtlpExporter());
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id) =>
{
using Activity? activity = source.StartActivity("LoadOrder");
activity?.SetTag("order.id", id);
return Results.Ok(new { id, status = "paid" });
});
app.Run();When to use it
- Send traces, metrics and logs to one collector so the backend can be changed later without touching code.
- Correlate a log line with the trace it belongs to in the same dashboard.
- Run the Aspire dashboard locally to see the telemetry your service will emit in production.
- Give one trace id to a support engineer and have it work across services written in different languages.
More examples
Configuring the exporter from the environment
These variable names are part of the OpenTelemetry standard, not of .NET, so the same names configure a Python or Go service beside yours. Keeping the endpoint out of the code is what lets one image run against a local collector, a staging collector and a vendor endpoint unchanged, which is exactly how you will want it when the app moves into Kubernetes.
# The OTLP exporter reads these standard variables itself.
# Default endpoint is http://localhost:4317 over gRPC.
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_SERVICE_NAME=shop-orders
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production
# A local collector with a UI, to see the same data while developing:
docker run --rm -p 18888:18888 -p 4317:18889 \
mcr.microsoft.com/dotnet/aspire-dashboard:latestConsole exporter, for checking the wiring
When traces are missing, the first question is whether they are being produced or being lost in transit. The console exporter answers it in one run: if spans print, the problem is the collector or the endpoint, and if they do not, the problem is a source you forgot to register or a sampler dropping everything. Swap it back out before shipping, because it writes a block of text per span.
// Package: OpenTelemetry.Exporter.Console
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddSource("Shop.Orders")
.AddConsoleExporter());Logs that carry the trace they belong to
The OpenTelemetry logging provider stamps each record with the trace id and span id of the activity that was current, which is what lets a backend show a trace's waterfall and that request's log lines on one screen. Without it you are back to matching timestamps by hand. IncludeScopes is off by default and is usually what you want on, since scopes are where request-level context such as a tenant id tends to live.
using OpenTelemetry.Logs;
builder.Logging.AddOpenTelemetry(logging =>
{
// Send the rendered message text, not only the template and arguments.
logging.IncludeFormattedMessage = true;
// Include the key-value pairs from any surrounding ILogger scope.
logging.IncludeScopes = true;
logging.AddOtlpExporter();
});
Discussion