Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for .NET: Traces, Metrics & Logs Guide

Instrument .NET apps with OpenTelemetry — ActivitySource traces, Meter metrics, ILogger logs — and ship everything to OpenObserve over OTLP.

OpenTelemetry guide

OpenTelemetry .NET is unusual among the language SDKs: instead of shipping its own tracing and metrics APIs, it builds on primitives that live in the .NET runtime itself — System.Diagnostics.ActivitySource for traces, System.Diagnostics.Metrics.Meter for metrics, and Microsoft.Extensions.Logging.ILogger for logs. That design has a practical payoff: libraries across the .NET ecosystem (including ASP.NET Core, HttpClient, and Entity Framework Core) already emit telemetry through these APIs, and the OpenTelemetry SDK simply collects and exports it.

This guide covers instrumenting a .NET application with the OpenTelemetry SDK — automatic and manual, all three signals — and shipping the data to OpenObserve over OTLP. If you are new to OpenTelemetry concepts like spans, resources, and OTLP, start with What is OpenTelemetry? and come back.

Why OpenTelemetry for .NET

  • All three signals are stable. OpenTelemetry .NET has shipped stable traces, metrics, and logs. There is no experimental caveat to work around — you can standardize your whole telemetry pipeline on it today.
  • Runtime-native APIs. Because instrumentation uses ActivitySource, Meter, and ILogger, your code takes no hard dependency on OpenTelemetry packages except at the composition root. Libraries you write stay vendor-neutral and OTel-free.
  • Vendor neutrality. Export over OTLP to OpenObserve, or to any other OTLP-compatible backend, by changing configuration — not code.
  • A real zero-code option. The OpenTelemetry .NET Automatic Instrumentation project bootstraps the managed SDK via the DOTNET_STARTUP_HOOKS startup hook on .NET (Core/5+) and via the CLR Profiler on .NET Framework, instrumenting existing apps — including .NET Framework apps — without a rebuild.
Signal.NET API surfaceStatus
TracesSystem.Diagnostics.ActivitySourceStable
MetricsSystem.Diagnostics.Metrics.MeterStable
LogsMicrosoft.Extensions.Logging.ILoggerStable

Everything below works with any .NET application model — ASP.NET Core, worker services, console apps, gRPC services. The examples use a minimal ASP.NET Core app only because it is the shortest way to show a runnable host.

Prerequisites

  • .NET 8 or later for the SDK examples (the SDK also supports .NET Framework 4.6.2+ and .NET Standard 2.0 targets; automatic instrumentation supports .NET Framework 4.6.2+).
  • An OpenObserve instance:
    • Cloud: sign up at OpenObserve Cloud and grab your organization name and ingestion credentials from Data Sources in the UI.
    • Self-hosted: a single binary or container is enough. docker run -p 5080:5080 public.ecr.aws/zinclabs/openobserve:latest gets you a local instance at http://localhost:5080.
  • Your Basic auth token. OpenObserve authenticates OTLP ingestion with HTTP Basic auth:
echo -n 'root@example.com:your-password' | base64
   # output: cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk

Keep that value handy; it goes into the Authorization: Basic <token> header on every export.

Zero-code: automatic instrumentation

If you want telemetry from an existing service without touching source, use OpenTelemetry .NET Automatic Instrumentation. On .NET (Core/5+) it bootstraps the managed SDK via the DOTNET_STARTUP_HOOKS startup hook, with the CoreCLR Profiler (CORECLR_PROFILER) used for additional monkey-patching; on .NET Framework it attaches via the CLR Profiler (COR_PROFILER). It injects instrumentation at runtime and covers popular libraries (ASP.NET Core, HttpClient, SQL clients, and more) out of the box.

Linux/macOS:

curl -sSfL https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/latest/download/otel-dotnet-auto-install.sh -O
sh ./otel-dotnet-auto-install.sh          # download and install the latest release

chmod +x $HOME/.otel-dotnet-auto/instrument.sh
. $HOME/.otel-dotnet-auto/instrument.sh   # enable it in the current shell

export OTEL_SERVICE_NAME="checkout-api"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk"
dotnet run

Windows (PowerShell, as Administrator):

$module_url = "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/latest/download/OpenTelemetry.DotNet.Auto.psm1"
$download_path = Join-Path $env:temp "OpenTelemetry.DotNet.Auto.psm1"
Invoke-WebRequest -Uri $module_url -OutFile $download_path -UseBasicParsing
Import-Module $download_path
Install-OpenTelemetryCore
Register-OpenTelemetryForCurrentSession -OTelServiceName "checkout-api"

There are also dedicated helpers for Windows Services (Register-OpenTelemetryForWindowsService) and IIS (Register-OpenTelemetryForIIS), which makes this the most practical route for legacy .NET Framework applications.

Automatic instrumentation gets you request-level traces and runtime metrics quickly, but it cannot know your business logic. For spans around your own operations, custom metrics, and structured logs, add manual instrumentation — the two approaches compose cleanly, since the auto-instrumentation is built on the same SDK.

Manual instrumentation

Install the core packages:

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.Runtime

OpenTelemetry.Extensions.Hosting wires the SDK into the generic host and dependency injection; it pulls in the core OpenTelemetry SDK package transitively. OpenTelemetry.Exporter.OpenTelemetryProtocol is the OTLP exporter used for all three signals. The three instrumentation packages light up incoming HTTP server spans and request-duration metrics, outgoing HttpClient spans, and .NET runtime metrics (GC, thread pool, JIT) respectively.

Traces

In .NET, a span is an Activity and a tracer is an ActivitySource. You create the source once (typically as a static readonly field), register its name with the SDK, and start activities around interesting work:

using System.Diagnostics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// One ActivitySource per component/assembly. Reuse it; never create per-request.
var activitySource = new ActivitySource("Shop.Checkout");

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: "checkout-api", serviceVersion: "1.4.2"))
    .WithTracing(tracing => tracing
        .AddSource("Shop.Checkout")          // must match the ActivitySource name
        .AddAspNetCoreInstrumentation()      // incoming HTTP spans
        .AddHttpClientInstrumentation()      // outgoing HTTP spans
        .AddOtlpExporter());                 // endpoint/headers come from env vars

var app = builder.Build();

app.MapGet("/checkout/{orderId}", async (string orderId) =>
{
    // Child span of the ASP.NET Core request span, automatically parented
    using var activity = activitySource.StartActivity("ProcessOrder");
    activity?.SetTag("order.id", orderId);

    try
    {
        await Task.Delay(50); // pretend to reserve inventory, charge card...
        activity?.SetTag("order.items", 3);
        activity?.SetStatus(ActivityStatusCode.Ok);
        return Results.Ok(new { orderId, status = "confirmed" });
    }
    catch (Exception ex)
    {
        activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
        activity?.AddException(ex);
        throw;
    }
});

app.Run();

Two things trip people up here. First, StartActivity returns null when no listener is sampling that source — hence the activity?. null-conditional calls; they are idiomatic, not defensive clutter. Second, .AddSource("Shop.Checkout") is mandatory: if the SDK is not subscribed to your ActivitySource name, your custom spans silently go nowhere. Wildcards like .AddSource("Shop.*") are supported.

Metrics

Metrics use System.Diagnostics.Metrics.Meter, also part of the runtime. Create instruments once and record measurements on the hot path:

using System.Diagnostics.Metrics;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

var meter = new Meter("Shop.Checkout", "1.0.0");
var ordersProcessed = meter.CreateCounter<long>(
    "shop.orders.processed",
    unit: "{order}",
    description: "Number of orders processed");
var checkoutDuration = meter.CreateHistogram<double>(
    "shop.checkout.duration",
    unit: "ms",
    description: "Time taken to process a checkout");

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: "checkout-api", serviceVersion: "1.4.2"))
    .WithMetrics(metrics => metrics
        .AddMeter("Shop.Checkout")           // must match the Meter name
        .AddAspNetCoreInstrumentation()      // http.server.request.duration etc.
        .AddRuntimeInstrumentation()         // GC, thread pool, exceptions
        .AddOtlpExporter());

var app = builder.Build();

app.MapGet("/checkout/{orderId}", (string orderId) =>
{
    var start = TimeProvider.System.GetTimestamp();

    // ... process the order ...

    var elapsed = TimeProvider.System.GetElapsedTime(start);
    ordersProcessed.Add(1, new KeyValuePair<string, object?>("payment.method", "card"));
    checkoutDuration.Record(elapsed.TotalMilliseconds);
    return Results.Ok(new { orderId });
});

app.Run();

The same registration rule applies: .AddMeter("Shop.Checkout") must match your Meter name or measurements are dropped. Keep attribute cardinality low — attribute values like user IDs or order IDs on a counter will explode your time series count.

Logs

Logging is where .NET’s integration is the tightest: OpenTelemetry hooks into Microsoft.Extensions.Logging, so every existing ILogger call in your app and your dependencies flows through the OTel pipeline unchanged. Structured template properties become log record attributes, and when a log is written inside an active span, the trace ID and span ID are attached automatically — that is what makes log-to-trace correlation work in OpenObserve.

using OpenTelemetry.Logs;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.SetResourceBuilder(ResourceBuilder.CreateDefault()
        .AddService(serviceName: "checkout-api", serviceVersion: "1.4.2"));
    logging.IncludeScopes = true;            // capture ILogger scopes as attributes
    logging.IncludeFormattedMessage = true;  // keep the rendered message text
    logging.AddOtlpExporter();
});

var app = builder.Build();

app.MapGet("/checkout/{orderId}", (string orderId, ILogger<Program> logger) =>
{
    // Structured properties, not string interpolation:
    logger.LogInformation("Processing order {OrderId} with {ItemCount} items",
        orderId, 3);
    return Results.Ok(new { orderId });
});

app.Run();

Use message templates ({OrderId}), not interpolated strings ($"...{orderId}..."). Templates preserve properties as queryable attributes and keep the message text low-cardinality; interpolation collapses everything into an opaque string. For a deeper treatment — log levels, scopes, correlation, and querying .NET logs in OpenObserve — see OpenTelemetry Logging in .NET Applications.

All three signals together

In a real service you configure everything once at startup:

using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("checkout-api", serviceVersion: "1.4.2"))
    .WithTracing(t => t
        .AddSource("Shop.Checkout")
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter())
    .WithMetrics(m => m
        .AddMeter("Shop.Checkout")
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeScopes = true;
    logging.AddOtlpExporter();
});

var app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();

Note that AddOtlpExporter() takes no arguments here. That is deliberate: the exporter reads the standard OTEL_EXPORTER_OTLP_* environment variables, which keeps credentials and endpoints out of your source code.

Sending data to OpenObserve

OpenObserve ingests OTLP natively — no collector required in between, though you can add one later for fan-out or processing (see A Beginner’s Guide to OTLP Exporters for when that makes sense).

Two transports are supported:

  • OTLP/HTTP (protobuf) on the main HTTP port (5080 by default self-hosted). Signal paths follow the pattern /api/<org>/v1/traces, /api/<org>/v1/metrics, /api/<org>/v1/logs.
  • OTLP/gRPC on port 5081 (self-hosted default, configurable via ZO_GRPC_PORT), which additionally requires an organization header.

For most .NET deployments, OTLP/HTTP is the right default: it needs only the base endpoint plus an auth header, and the SDK appends the per-signal /v1/... path itself.

Self-hosted OpenObserve:

export OTEL_SERVICE_NAME="checkout-api"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk"

dotnet run

OpenObserve Cloud:

export OTEL_SERVICE_NAME="checkout-api"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <your-base64-token>"

dotnet run

Replace <your-org> and the token with the values shown under Data Sources in your OpenObserve Cloud UI, which displays copy-paste-ready OTLP settings for your account.

Three details matter:

  1. Do not end the endpoint with a trailing slash. The exporter appends /v1/traces (and so on) to the base endpoint; a trailing slash produces a malformed path.
  2. OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf is required. The .NET OTLP exporter defaults to gRPC. Without this variable it will try gRPC framing against the HTTP port and every export will fail.
  3. The header value format is Authorization=Basic <token> — note = between the header name and value, and a literal space inside the value.

If you prefer configuring in code (for example, from appsettings.json via the options pattern), the equivalent is:

using OpenTelemetry.Exporter;

.AddOtlpExporter(options =>
{
    // For HttpProtobuf, the .NET exporter uses the URI as-is,
    // so include the full per-signal path:
    options.Endpoint = new Uri("http://localhost:5080/api/default/v1/traces");
    options.Protocol = OtlpExportProtocol.HttpProtobuf;
    options.Headers = "Authorization=Basic cm9vdEBleGFtcGxlLmNvbTp5b3VyLXBhc3N3b3Jk";
})

Mind the asymmetry: with environment variables you supply the base endpoint and the SDK appends the signal path; with a programmatic Endpoint in HttpProtobuf mode you must supply the full path per signal. Environment variables are less error-prone — use them unless you have a reason not to.

Verifying data arrival in OpenObserve

Generate some traffic first:

for i in $(seq 1 20); do curl -s http://localhost:8080/checkout/order-$i > /dev/null; done

Then check each signal in the OpenObserve UI:

  1. Traces: open the Traces page, select your organization, and filter by service name (checkout-api). You should see the ASP.NET Core request spans with your custom ProcessOrder child spans nested inside; click one to see the flame graph, span attributes like order.id, and any recorded exceptions.
  2. Logs: open Logs and select the default log stream (or whichever stream you configured). Log records carry your structured attributes plus trace_id and span_id fields when they were emitted inside a span, so you can pivot from a slow trace to exactly the logs it produced.
  3. Metrics: open Streams and look for metric streams named after your instruments (for example, shop_orders_processed — OTLP metric names are normalized for storage). Query them from the Metrics explorer or a dashboard panel.

If nothing shows up within a minute, jump to the troubleshooting section — the cause is almost always one of five things.

Production tips

Batching is the default — keep it. The SDK exports traces and logs through a BatchExportProcessor and metrics through a PeriodicExportingMetricReader out of the box when you use AddOtlpExporter(). Do not switch to simple/synchronous export in production; it turns every span end into a network call. Tune with the standard variables if needed (OTEL_BSP_MAX_QUEUE_SIZE, OTEL_BSP_SCHEDULE_DELAY, OTEL_METRIC_EXPORT_INTERVAL).

Sample at the head, deliberately. Full tracing of a high-throughput API is rarely worth the cost. The standard approach is parent-based ratio sampling, which keeps trace trees intact — if the incoming request was sampled, all its children are too:

export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
export OTEL_TRACES_SAMPLER_ARG="0.1"   # keep 10% of root traces

Start at 100% in staging, then dial down in production based on volume.

Invest in resource attributes. service.name, service.version, and deployment.environment are the axes you will filter every query by. Set them once via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES so all three signals share identical resource identity — that consistency is what makes cross-signal correlation work.

Flush on shutdown. When you use AddOpenTelemetry() with the generic host, the SDK’s TracerProvider, MeterProvider, and logger provider are disposed on graceful shutdown and flush pending telemetry. Two caveats: give the host time to drain (the default HostOptions.ShutdownTimeout may be tight if your OTLP endpoint is slow), and in short-lived console apps that build the SDK manually, explicitly call tracerProvider.ForceFlush() / Dispose() before exit or you will lose the tail of your data.

Watch instrument and attribute cardinality. The metrics SDK caps the number of attribute combinations per instrument (overflow measurements are folded into an overflow attribute). If a counter tagged with high-cardinality values seems to “stop counting,” cardinality limits are why. Keep IDs out of metric attributes; put them on spans and logs instead.

.NET-specific pitfalls:

  • Creating ActivitySource or Meter per request instead of once per process — both are designed to be long-lived statics.
  • Forgetting .AddSource(...) / .AddMeter(...): the runtime APIs no-op silently without a subscribed listener, so there is no exception to alert you.
  • Assuming Activity.Current flows across manual thread hops. It flows with async/await via AsyncLocal, but fire-and-forget work queued without the execution context loses the current span.
  • On .NET Framework or older libraries, Activity.DefaultIdFormat legacy hierarchical IDs can break context propagation; modern SDK versions default to W3C, but check if you see orphaned spans.

Troubleshooting

Symptom: every export fails, or the app logs gRPC/HttpRequestException errors, and nothing reaches OpenObserve. Fix: you are almost certainly sending gRPC to the HTTP port. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf (the .NET exporter defaults to gRPC), or point gRPC exports at port 5081 instead of 5080.

Symptom: HTTP 401 responses from OpenObserve. Fix: the Basic token is wrong or missing. Regenerate it with echo -n 'email:password' | base64 — the -n matters; a trailing newline inside the encoded value breaks authentication. Confirm the header syntax is exactly OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <token>".

Symptom: HTTP 404 responses from OpenObserve. Fix: check the endpoint path. It must include your organization (/api/<org>) and must not end with a trailing slash, since the SDK appends /v1/traces itself. A doubled path like /api/default//v1/traces or a missing org segment both 404.

Symptom: auto-instrumented spans arrive, but your custom ActivitySource spans never do. Fix: the SDK is not listening to your source. Add .AddSource("Your.Source.Name") with the exact name (or a wildcard) to WithTracing. The same applies to metrics with .AddMeter(...). Remember StartActivity returning null is the tell-tale sign no listener is sampling.

Symptom: traces appear, but logs in OpenObserve have no trace_id, so correlation is broken. Fix: the log must be written while an Activity is current. Verify the log call happens inside the span’s using scope and that tracing is actually configured in the same process. If you route logs through a third-party framework (Serilog, NLog) instead of builder.Logging.AddOpenTelemetry(...), use that framework’s OpenTelemetry sink/target or bridge back into Microsoft.Extensions.Logging.

Symptom: telemetry from a short-lived console app or background job is missing its final spans. Fix: the batch processor buffered data that was never flushed. Dispose the TracerProvider/MeterProvider (or call ForceFlush()) before the process exits, and increase the host shutdown timeout if exports to a remote endpoint are slow.

Symptom: a metric stream shows far fewer series than expected, or values pile up under an overflow attribute. Fix: you hit the SDK’s cardinality limit for that instrument. Reduce distinct attribute values (drop per-user or per-request IDs from metric attributes) or raise the limit deliberately if the cardinality is genuinely needed.

With the SDK wired up and OTLP pointed at OpenObserve, you have a complete, stable, vendor-neutral observability pipeline for .NET — traces, metrics, and logs correlated end to end, with nothing proprietary baked into your application code.

Frequently asked questions

Are traces, metrics, and logs all stable in OpenTelemetry .NET?

Yes. OpenTelemetry .NET is one of the few language SDKs where all three signals — traces, metrics, and logs — are marked stable. You can use the same SDK and OTLP exporter for all of them in production.

What is the difference between ActivitySource and the OpenTelemetry Tracer API?

In .NET, OpenTelemetry tracing is built directly on the System.Diagnostics.ActivitySource and Activity types that ship with the .NET runtime. An Activity is a span and an ActivitySource is a tracer. You instrument with the built-in .NET APIs, and the OpenTelemetry SDK collects and exports that data — there is no separate Tracer API to learn.

Do I need to change my logging code to use OpenTelemetry in .NET?

No. OpenTelemetry .NET integrates with Microsoft.Extensions.Logging, so existing ILogger calls flow through the OpenTelemetry log pipeline once you call AddOpenTelemetry on the logging builder. Structured log properties become log record attributes, and trace and span IDs are attached automatically when logs are written inside an active span.

Does OpenTelemetry .NET support automatic instrumentation without code changes?

Yes. The OpenTelemetry .NET Automatic Instrumentation project instruments applications at runtime with no source changes: on .NET (Core/5+) it bootstraps the managed SDK via the DOTNET_STARTUP_HOOKS startup hook (OpenTelemetry.AutoInstrumentation.StartupHook.dll), with the CoreCLR Profiler (CORECLR_PROFILER) used for additional monkey-patching; on .NET Framework the CLR Profiler (COR_PROFILER) is the mechanism. You install it with a shell or PowerShell script, set a few environment variables, and it emits traces, metrics, and logs for supported libraries such as ASP.NET Core, HttpClient, and popular database clients. It supports .NET and .NET Framework 4.6.2 or later.

Should I use OTLP over gRPC or HTTP to send data to OpenObserve?

OpenObserve accepts both. OTLP over HTTP (protobuf) on the main port 5080 is the simplest option and works through most proxies and load balancers. OTLP over gRPC on port 5081 offers lower per-request overhead at high volume but requires HTTP/2 and an extra organization header. Start with HTTP unless you have a specific throughput reason to use gRPC.

How do I authenticate .NET OTLP exports to OpenObserve?

OpenObserve uses HTTP Basic authentication. Base64-encode your user email and password or ingestion token as email:password, then send it in the Authorization header, typically via the OTEL_EXPORTER_OTLP_HEADERS environment variable set to Authorization=Basic followed by the encoded value.

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo