OpenTelemetry в .NET: трейсы, метрики и логи с минимумом шаблонного кода
Published: 2026-03-13
OpenTelemetry — стандарт наблюдаемости, заменяющий SDK отдельных вендоров. Инструментирование настраивается один раз, экспортёры меняются в конфигурации. Для .NET-приложений в Kubernetes это трейсы в Jaeger, метрики в Prometheus и структурированные логи в любой нужный приёмник — всё из одной инициализации SDK.
NuGet-пакеты
xml<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.*" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.*-beta*" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.*" />
Пакеты разделены: инструментирование (что собирать) и экспортёры (куда отправлять). Для публикации метрик через /metrics используйте Prometheus-экспортёр AspNetCore.
Настройка SDK в Program.cs
csharpvar builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(
serviceName: "my-service",
serviceVersion: Environment.GetEnvironmentVariable("APP_VERSION") ?? "dev"))
.AddAspNetCoreInstrumentation(opts =>
{
opts.RecordException = true;
opts.Filter = ctx => ctx.Request.Path != "/healthz";
})
.AddHttpClientInstrumentation()
.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri(
builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]
?? "http://otel-collector.observability.svc.cluster.local:4317");
}))
.WithMetrics(metrics => metrics
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("my-service"))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter());
var app = builder.Build();
app.MapPrometheusScrapingEndpoint();
Переменные окружения для конфигурации
yamlenv:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector.observability.svc.cluster.local:4317"
- name: OTEL_SERVICE_NAME
value: "my-service"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "deployment.environment=prod,k8s.namespace=app"
- name: APP_VERSION
value: "1.2.3"
SDK читает OTEL_SERVICE_NAME автоматически. Никаких изменений кода между окружениями.
Кастомные трейсы: спаны для бизнес-операций
csharpprivate static readonly ActivitySource _tracer =
new ActivitySource("my-service.business");
public async Task<Order> ProcessOrderAsync(Guid orderId)
{
using var span = _tracer.StartActivity("ProcessOrder");
span?.SetTag("order.id", orderId.ToString());
try
{
var order = await _repo.GetAsync(orderId);
span?.SetTag("order.status", order.Status.ToString());
span?.SetTag("order.amount", order.Total);
await _paymentService.ChargeAsync(order);
span?.SetStatus(ActivityStatusCode.Ok);
return order;
}
catch (Exception ex)
{
span?.SetStatus(ActivityStatusCode.Error, ex.Message);
span?.RecordException(ex);
throw;
}
}
Регистрация источника:
csharp.WithTracing(tracing => tracing
.AddSource("my-service.business")
...
Кастомные метрики
csharpprivate static readonly Meter _meter = new Meter("my-service.business");
private static readonly Counter<long> _ordersProcessed =
_meter.CreateCounter<long>("orders_processed_total");
private static readonly Histogram<double> _orderAmount =
_meter.CreateHistogram<double>("order_amount_eur");
_ordersProcessed.Add(1, new TagList { { "status", "success" } });
_orderAmount.Record(order.Total, new TagList { { "currency", "EUR" } });
Регистрация:
csharp.WithMetrics(metrics => metrics
.AddMeter("my-service.business")
...
Связь логов с трейсами
csharpbuilder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri(
builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]
?? "http://otel-collector.observability.svc.cluster.local:4317");
});
});
Каждая запись лога получает атрибуты trace_id и span_id. В Grafana можно переходить от спана трейса к связанным логам.
Развёртывание OTel Collector
yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: otel-collector
namespace: observability
spec:
chart:
spec:
chart: opentelemetry-collector
version: "0.x"
sourceRef:
kind: HelmRepository
name: open-telemetry
values:
mode: deployment
config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observability.svc.cluster.local:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp/jaeger]
Приложения отправляют в Collector; Collector распределяет на несколько бэкендов. Смена бэкенда не требует изменений в приложении.
ServiceMonitor для скрейпинга Prometheus
yamlapiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: my-service
namespace: observability
labels:
release: kube-prom-stack
spec:
selector:
matchLabels:
app: my-service
endpoints:
- port: http
path: /metrics
interval: 30s
namespaceSelector:
matchNames:
- app
Диагностика: трейсы не появляются
bash# Проверить доступность Collector из пода
kubectl exec -n app deploy/my-service -- \
wget -qO- http://otel-collector.observability.svc.cluster.local:4317
# Включить debug-логирование OTel
- name: OTEL_LOG_LEVEL
value: "debug"
# Логи коллектора
kubectl logs -n observability deploy/otel-collector --tail=50
Частые причины: неправильный namespace в URL OTLP-эндпоинта, отсутствующий insecure: true в экспортере коллектора, AddSource не вызван для кастомных ActivitySource.