C# / .NET instrumentation
Instrument a .NET application with OpenTelemetry and send traces, logs, and metrics to Maple.
This guide sets up the OpenTelemetry .NET SDK in an ASP.NET Core or generic-host application so it sends traces, logs and metrics to Maple, with instrumentation for ASP.NET Core and HttpClient.
To have a coding agent do this setup, use the maple-onboard skill, and maple-audit to check an existing setup.
Prerequisites
- .NET 8+ (the packages also target .NET Framework 4.6.2+)
- An ingest key from Settings → Ingestion in Maple. Use the private key (
maple_sk_…) for server applications.
Install
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
Configure
Wire OpenTelemetry into the host builder in Program.cs. Each signal gets its own OTLP exporter:
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
const string MapleEndpoint = "https://ingest.maple.dev"; // EU: https://ingest.eu.maple.dev
const string MapleHeaders = "Authorization=Bearer YOUR_INGEST_KEY";
void ConfigureMaple(OtlpExporterOptions opts, string path)
{
opts.Endpoint = new Uri($"{MapleEndpoint}{path}");
opts.Protocol = OtlpExportProtocol.HttpProtobuf;
opts.Headers = MapleHeaders;
}
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName: "my-dotnet-app", serviceVersion: "1.0.0")
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment.name"] = builder.Environment.EnvironmentName,
["vcs.repository.url.full"] = "https://github.com/acme/my-dotnet-app",
}))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter(opts => ConfigureMaple(opts, "/v1/traces")))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter(opts => ConfigureMaple(opts, "/v1/metrics")))
.WithLogging(logging => logging
.AddOtlpExporter(opts => ConfigureMaple(opts, "/v1/logs")));
var app = builder.Build();
app.MapGet("/", () => "Hello!");
app.Run();
When you set Endpoint in code, it must include the signal path (/v1/traces, /v1/metrics, /v1/logs). The exporter’s default protocol is gRPC, so set HttpProtobuf on each one.
The example puts the endpoint and key in source. An ingest key can only write telemetry to your organization. It cannot read data or call the Maple API. Keeping it in source means the SDK always starts with a complete configuration, so a deploy that is missing an environment variable cannot silently turn telemetry off. To keep the key out of source, use environment variables instead.
For a console app or worker without a host, build the providers directly and dispose them on exit:
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.ConfigureResource(r => r.AddService("my-dotnet-worker"))
.AddSource("my-dotnet-worker")
.AddHttpClientInstrumentation()
.AddOtlpExporter(opts => ConfigureMaple(opts, "/v1/traces"))
.Build();
using var meterProvider = Sdk.CreateMeterProviderBuilder()
.ConfigureResource(r => r.AddService("my-dotnet-worker"))
.AddMeter("my-dotnet-worker")
.AddOtlpExporter(opts => ConfigureMaple(opts, "/v1/metrics"))
.Build();
Environment variables
UseOtlpExporter() sets up one OTLP exporter for every signal you enabled and reads its settings from the standard environment variables:
export OTEL_SERVICE_NAME="my-dotnet-app"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,vcs.repository.url.full=https://github.com/acme/my-dotnet-app"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation())
.WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation())
.WithLogging()
.UseOtlpExporter();
With http/protobuf, the exporter appends /v1/traces, /v1/metrics and /v1/logs to the base endpoint. Traces, metrics and logs are all exported, because each one is enabled above.
Auto-instrumentation
.NET instrumentation comes from one NuGet package per library, registered on the tracing or metrics builder with .AddXxxInstrumentation():
| Library | Package |
|---|---|
| ASP.NET Core | OpenTelemetry.Instrumentation.AspNetCore |
| HttpClient | OpenTelemetry.Instrumentation.Http |
| Entity Framework Core | OpenTelemetry.Instrumentation.EntityFrameworkCore |
| SqlClient | OpenTelemetry.Instrumentation.SqlClient |
| StackExchange.Redis | OpenTelemetry.Instrumentation.StackExchangeRedis |
| gRPC client | OpenTelemetry.Instrumentation.GrpcNetClient |
| .NET runtime metrics | OpenTelemetry.Instrumentation.Runtime |
ASP.NET Core turns each incoming request into a server span and records request duration metrics. HttpClient calls become client spans that carry the trace context to the service you call.
Custom spans
In .NET, spans are System.Diagnostics.Activity objects. Create an ActivitySource and start activities from it:
using System.Diagnostics;
public class OrderService
{
private static readonly ActivitySource ActivitySource = new("MyApp.Orders");
public async Task ProcessOrder(string orderId)
{
using var activity = ActivitySource.StartActivity("process-order");
activity?.SetTag("order.id", orderId);
activity?.SetTag("payment.method", "card");
try
{
await ChargePayment(orderId);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
throw;
}
}
}
Register the source name so its activities are exported:
.WithTracing(tracing => tracing
.AddSource("MyApp.Orders")
// ...
)
Service map edges come from instrumented client spans that propagate traceparent to an instrumented callee, not from attributes such as peer.service. See Service map.
Log correlation
With WithLogging() configured, ILogger<T> records are exported as OpenTelemetry log records. Records written during an active activity carry its trace and span IDs, so Maple links each log line to its trace:
public class OrderService(ILogger<OrderService> logger)
{
public async Task ProcessOrder(string orderId)
{
logger.LogInformation("Processing order {OrderId}", orderId);
}
}
Verify
- Run your application (
dotnet run) and send it a few requests. - In Maple, open Explore → Traces. The SDK sends spans in batches every 5 seconds by default, and metrics every 60 seconds.
- Each request should show up as one trace with a single root server span, named after the method and route (for example
GET /api/orders), and child spans for the queries and outgoing calls it made.
Your service also appears on the Services page once its first spans arrive.
Troubleshooting
401responses. The key is wrong, was copied from the other region, or the header is malformed.OtlpExporterOptions.HeadersandOTEL_EXPORTER_OTLP_HEADERSboth takeAuthorization=Bearer YOUR_INGEST_KEY. See Ingest API status codes.- Wrong protocol or path. Maple accepts OTLP over HTTP, and the .NET exporter defaults to gRPC. Set
OtlpExportProtocol.HttpProtobufin code orOTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf. An endpoint set in code needs the full signal path; the environment variable takes only the base URL. - Network. From the machine running the app, run
curl -i https://ingest.maple.dev/v1/traces -X POST. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS. - Nothing exported. Custom spans only export when their
ActivitySourcename is registered withAddSource, and custom metrics when theirMetername is registered withAddMeter. In console apps, dispose the providers before exit so the last batch is sent.