Привет, Хабр!
TL;DR:
Поднимаем стенд в docker-compose (gateway + api + postgres + otel-collector + SigNoz/ClickHouse).
Делаем 3 запроса: быстрый / медленный / с исключением.
Смотрим в SigNoz трейсы (включая DB span), метрики и логи с привязкой к trace_id.
Разбираем, как это конфигурируется в .NET (Resource, OTLP export, логирование).
Когда сервисов становится больше одного, отладка по логам быстро превращается в квест: ошибка всплывает в одном месте, причина - в другом. Без хорошей телеметрии инженерам сложно понять, что происходит внутри распределённой системы. Сегодня стандартом сбора телеметрии стал OpenTelemetry - проект CNCF и результат слияния OpenCensus и OpenTracing. OpenTelemetry поддерживают крупнейшие сервисы и инструменты, а в .NET начиная с версии 6 его можно внедрить практически из коробки.
В этой статье я покажу, как с нуля подключить OpenTelemetry в ASP.NET Core проект и получить полноценную наблюдаемость: распределённые трейсы, метрики и логи. Мы не будем углубляться в теорию (что такое спаны/трейсы/метрики и почему это важно) - сфокусируемся на практике.
Мы развернём небольшой "микросервисный" стенд в Docker Compose и после пары запросов увидим в SigNoz полный набор сигналов: трейсы, метрики и логи с корреляцией по trace_id. Все исходники доступны в репозитории OtelDotnetExample, так что каждый шаг можно повторить самостоятельно.
Итак, начнём.
Фактура для наблюдаемости
Чтобы продемонстрировать наблюдаемость, нам нужен работающий сервис с разными видами операций, где есть:
Входящий HTTP-запрос - чтобы увидеть серверные спаны (трейсы входящих запросов)
Исходящий HTTP-запрос - посмотреть распределённый трейс между сервисами
Работа с базой данных - увидеть спаны БД
Runtime-метрики - убедиться, что приложение работает (сборки мусора, потоки и т.д.)
Логи - куда же без них
Соберём такой стек сервисов:
gateway - входной API-сервис (с Swagger UI), который проксирует запросы в сервис api
api - второй сервис с базой PostgreSQL. Умеет отвечать быстро, медленно или с ошибкой (сымитируем разные сценарии для наглядности)
postgres - база данных для сервиса api
otel-collector - OpenTelemetry Collector, принимает данные по OTLP (трейсы/метрики/логи) и экспортирует в хранилище
clickhouse + SigNoz - хранилище (ClickHouse) и UI для отображения наблюдаемости.
SigNoz 2 - это open-source платформа observability, построенная на OpenTelemetry: в одном интерфейсе видны метрики, трейсы и логи.
Для примера я использую .NET 10, но можно развернуть и на .NET 8/9 - отличаться будут разве что версии NuGet-пакетов.
Общая архитектура нашего примера такая:

В проекте всего один endpoint GET /v1/weather в сервисе gateway, который проксирует запрос в api. Он управляется query-параметрами:
delayMs- задержка ответа для имитации медленного запросаthrowException- сгенерировать исключение для имитации ошибки
Дальше в статье рассмотрим:
Как запустить всё это локально в Docker
Где смотреть трейсы/метрики/логи в SigNoz
Какие куски кода в .NET отвечают за отправку телеметрии
Добавление дополнительных спанов и тегов
Быстрый старт: запускаем и собираем телеметрию
Предварительные требования: установлен Docker Desktop (или Docker Engine + Docker Compose v2) и свободные порты 5000, 5001, 8080 (при желании можно поменять, а также убедитесь, что не заняты порты 5432 для Postgres и 8123 для ClickHouse).
Для запуска клонируйте репозиторий OtelDotnetExample:
git clone git@github.com:makushevski/OtelDotnetExample.git
cd OtelDotnetExample
Выполните команду из корня проекта:
docker compose up --build
Нужно немного подождать пока всё запустится, прогреется и прогонятся миграции.
Что должно подняться в итоге:
Gateway Swagger - откройте в браузере http://localhost:5000/swagger - это UI входного сервиса.
SigNoz UI - откройте http://localhost:8080. Введите любую почту и придумайте пароль.
Теперь сгенерируем немного телеметрии. Можно просто потыкать ручками запросы в браузере - все они простой GET.
Swagger в нашем случае нужен просто потому что можем и на случай если захотите добавить свои эндпоинты.
# Быстрый запрос (без параметров)
http://localhost:5000/v1/weather
# Медленный запрос (3 секунды задержки)
http://localhost:5000/v1/weather?delayMs=3000
# Ошибочный запрос (сгенерировать исключение)
http://localhost:5000/v1/weather?throwException=true
После этих трёх запросов у нас уже есть что посмотреть:
У кейса с искусственной задержкой мы ожидаем в трейсе заметную длительность выполнения
У кейса с ошибкой в трейсе появится пометка и информация об исключении
В логах должны появиться записи от сервисов
otel-example-gatewayиotel-example-api(включая stacktrace для ошибки), причём они будут привязаны к тому же trace_id, что и спаны запросов
Смотрим результаты в SigNoz
Дальше просто идём в SigNoz UI и проверяем, что реально собирается.
Трейсы
Открываем SigNoz UI и идем в раздел Traces. Откройте любой недавний трейс - раскроется структура спанов (таймлайн запроса).

Здесь можно провалиться внутрь, кликнув на спан, и увидеть всю распределённую трассу.

В нашем примере она включает:
Серверный спан на gateway (входящий HTTP-запрос от клиента)
Клиентский спан gateway -> api (исходящий HTTP-запрос от gateway к сервису api)
Серверный спан на api (обработка запроса сервисом api)
DB-спан (вызов PostgreSQL через EF Core внутри api)
Если вы вызывали throwException=true, то на уровне спанов api будет пометка об исключении + в деталях спана можно увидеть сведения об ошибке - тип и сообщение исключения.

Посмотрите внимательно на таймлайн: при кейсе с задержкой спаны gateway и api суммарно дадут ~3 секунды, и хорошо видно, где была пауза. Трейс позволяет чётко проследить путь запроса через сервисы и понять, где случилась проблема.

Метрики
В разделе Metrics мы найдём метрики по сервисам:
Гистограммы и счётчики HTTP-запросов
http.server.request.*Метрики runtime .NET - например,
dotnet.gc.*(сборки мусора),dotnet.thread_pool.*(потоки) и прочее.
Эти показатели даёт AddRuntimeInstrumentation(). Такие runtime-метрики позволяют понять состояние приложения. Например, заметив всплеск пауз GC или рост потоков, можно догадаться, что дело не в медленном SQL-запросе, а, например, в тормозящем GC. Но эти графики придется построить самостоятельно.
SigNoz по умолчанию строит графики для каждого сервиса с некоторыми метриками (длительность запросов, ошибки, обращения к БД и внешним сервисам и т.п.), а при необходимости вы можете строить свои графики по любым собранным метрикам.

Логи
В разделе Logs видны журналы логов сервисов. Логи сразу привязаны к трассировкам и каждая запись лога содержит TraceId и SpanId. В UI SigNoz можно отфильтровать логи по trace_id или нажать на иконку рядом с полем trace_id, чтобы сразу перейти к соответствующему трейсу. Таким образом, выполнив проблемный запрос, вы можете в одном интерфейсе посмотреть и сам трейс, и все связанные с ним сообщения.

Как это устроено внутри: четыре главных момента в .NET
Перейдём к тому, что нужно сделать в коде .NET, чтобы всё вышеперечисленное заработало.
OpenTelemetry для .NET предоставляет удобные расширения, и значительную часть работы делает за нас SDK 3. Однако есть несколько важных моментов, без которых телеметрия либо не соберётся, либо будет бесполезной.
Эти ключевые моменты:
Настройка ResourceBuilder - задать имя сервиса и общие атрибуты.
Подключение инструментирования (tracing и metrics) - включить автосбор трасс для ASP.NET, HttpClient, БД, а также метрик runtime.
Настройка экспорта - указать, куда и как отправлять собранные трейсы/метрики/логи (в нашем случае в Collector по протоколу OTLP).
Рассмотрим каждую из этих частей подробнее.
1. ResourceBuilder
Во всех приложениях (и в api, и в gateway) в файле Program.cs сначала задаётся имя сервиса и базовые атрибуты ресурса через ResourceBuilder:
var serviceName = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME")
?? builder.Configuration["Service:Name"]
?? builder.Environment.ApplicationName;
var resourceBuilder = ResourceBuilder.CreateDefault().AddService(serviceName);
Service Name - это главный идентификатор сервиса во всех дашбордах и фильтрах.
Resource Attributes - позволяют навесить на всю телеметрию сервиса дополнительные атрибуты: namespace, версия, окружение и т.д. Эти атрибуты потом используются в фильтрах, дашбордах, алертах.
Если сервисов много, удобно вынести настройку
ResourceBuilder(service.name, service.version, environment, namespace) в общую библиотеку. Так вы стандартизируете атрибуты и избежите ситуации, когда метрики/трейсы от разных команд нельзя нормально склеить фильтрами.
В нашем docker-compose.yml это выглядит так:
services:
api:
environment:
OTEL_SERVICE_NAME: otel-example-api
OTEL_RESOURCE_ATTRIBUTES: service.namespace=otel-dotnet-example,service.version=1.0.0,deployment.environment=local
services:
gateway:
environment:
OTEL_SERVICE_NAME: otel-example-gateway
OTEL_RESOURCE_ATTRIBUTES: service.namespace=otel-dotnet-example,service.version=1.0.0,deployment.environment=local
Правильно задав service.name и ресурсные метаданные, мы добьемся того, чтобы сервисы были чётко разграничены в наблюдаемости и каждый спан/метрика/лог снабжены контекстом (версия, окружение и пр.).
2. Включение трассировки: ASP.NET Core, HttpClient, база данных
OpenTelemetry .NET предлагает готовые инструменты интеграции для популярных решений: веб-сервер, HTTP-клиент, базы данных и т.д.
В нашем сервисе api конфигурация трассировки выглядит так:
builder.Services.AddOpenTelemetry()
.WithTracing(tracerProviderBuilder => tracerProviderBuilder
.SetResourceBuilder(resourceBuilder)
.AddSource("OtelDotnetExample.Api")
.AddAspNetCoreInstrumentation(options => options.RecordException = true)
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter());
Аналогичный код есть в сервисе gateway (только без AddEntityFrameworkCoreInstrumentation(), так как gateway не работает с базой данных).
Что тут конфигурируется:
Регистрация источника Activity для нашего приложения:
AddSource("OtelDotnetExample.Api")- нужно для сбора ручных спанов, которые мы создаём сами в коде. ИмяOtelDotnetExample.Apiдолжно совпадать с именем, используемым при создании ActivitySource в приложении.Авто-инструментация входящих HTTP-запросов:
AddAspNetCoreInstrumentation()- включает автоматическое создание спанов для всех входящих HTTP запросов в ASP.NET Core. Каждый запрос к контроллеру будет обёрнут в Activity (спан), содержащий информацию о пути, методе, коде ответа и прочее. Мы дополнительно указалиoptions.RecordException = true, чтобы в случае необработанного исключения OTel пометил спан как ошибочный и записал информацию об исключении. Без этой опции вы бы видели в трейсе просто спан с кодом 500, но без деталей ошибки.Авто-инструментация исходящих HTTP-запросов:
AddHttpClientInstrumentation()- включает спаны на исходящие HTTP запросы, выполняемые через HttpClient . В нашем случае вызов из gateway к api автоматически будет оформлен как отдельный клиентский спан. Далее, библиотека сама пробросит контекст трассы дальше по цепочке (через заголовокtraceparentпо спецификации W3C Trace Context - на стороне api серверная инструментация подхватит этот контекст, и мы увидим единый сквозной трейс, охватывающий оба сервиса.Инструментация БД:
AddEntityFrameworkCoreInstrumentation()- добавляет DB-спаны для операций EF Core. Во многих конфигурациях в спане также виден SQL-запрос, но это зависит от версии и настроек - в продакшене включать полные SQL стоит осознанно из-за риска утечки секретов. На моей версии EF Core видно db.statement, но в других версиях может отличаться.Экспортёр:
AddOtlpExporter()- отправляет все собранные сигналы на указанный OTLP-эндпоинт. В нашем случае, OTLP-адрес задаётся переменными окружения: OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 и протокол gRPC. Это указано в docker-compose.yml для сервисов. Мы выбрали gRPC на порту 4317 (вполне стандартно для OTLP). Можно было отправлять и по HTTP (порт 4318), но gRPC обычно предпочтительнее.
Хорошей практикой считается управление конфигурацией Otel через переменные окружения. Это сделано чтобы унифицировать управление для разных сервисов на разных технологиях. Для этого у Otel есть своя спецификация для переменных окружения 4. Но надо иметь ввиду что реализация для разных SDK она может немного отличаться от спецификации и следует сверяться с официальной документацией (или исходным кодом) для конкретного SDK.
3. Включение метрик: количество запросов и состояние .NET
Помимо трассировки, мы подключили сбор метрик. В коде это делается похожим образом:
builder.Services.AddOpenTelemetry()
.WithMetrics(meterProviderBuilder => meterProviderBuilder
.SetResourceBuilder(resourceBuilder)
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter());
Что конфигурируется для метрик:
Аналогично трейсам мы включили AddAspNetCoreInstrumentation() и AddHttpClientInstrumentation() для метрик. Они дадут стандартные метрики по HTTP-запросам (длительность, счетчики, ошибки). Метрики рантайма AddRuntimeInstrumentation() - добавляет поток метрик с информацией о runtime .NET: количество сборок мусора, размер кучи, количество потоков, использование CPU и др. Эти метрики позволяют увидеть, как "чувствует себя" CLR (вдруг высокое время GC stop-the-world, и поэтому запросы медленные - такое сразу станет заметно на графике).
Tracing и Metrics можно конфигурировать и в одном вызове AddOpenTelemetry(), тут я разделил для наглядности.
4. Экспорт: куда улетает телеметрия (Collector)
Последний кусок - настройка экспорта. В коде мы уже видели AddOtlpExporter(). По умолчанию он читает параметры из переменных окружения (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL и др.). В нашем compose-файле мы задали такие переменные:
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_TRACES_SAMPLER: always_on
Таким образом, оба приложения (gateway и api) отправляют все сигналы (trace/metrics/logs) на коллектор otel-collector по gRPC. Коллектор, в свою очередь, настроен принимать OTLP-телеметрию и слать её в ClickHouse (через специальный SigNoz exporter). SigNoz из коробки идёт с такой конфигурацией, там не пришлось ничего доделывать 7.
Флаг OTEL_TRACES_SAMPLER: always_on: он включает 100% сбор трейсов. Для демонстрации это нормально так как мы хотим видеть каждый запрос. В проде собирать все трейсы подряд обычно нецелесообразно. Рекомендуется делать сэмплирование 6.
Ручной спан в контроллере (ActivitySource.StartActivity())
В коде сервиса api (см. WeatherController.cs) есть такой фрагмент:
using var activity = ActivitySource.StartActivity("WeatherController.GetWeather");
activity?.SetTag("app.throwException", throwException);
activity?.SetTag("app.delayMs", delayMs);
Этот код создаёт новый вложенный спан внутри обработки запроса. Мы указываем ему имя (WeatherController.GetWeather) и добавляем пару кастомных тегов - параметры запроса. Чтобы такой спан вообще появился, важно, что мы зарегистрировали его источник: при настройке трейсера было AddSource("OtelDotnetExample.Api"). В WeatherController мы используем именно тот самый ActivitySource из нашего кода (определён в классе Telemetry.cs как ActivitySource("OtelDotnetExample.Api")).
Возникает вопрос: зачем он нужен, если входящий HTTP-запрос и так померен автоматикой? Ответ - для дополнительной детализации бизнес-логики. Автоматический спан на уровне ASP.NET Core скажет нам только общее время выполнения контроллера. Но если внутри контроллера есть разные этапы (например, обращение к нескольким системам, какие-то вычисления), иногда полезно явно померить отдельный этап. В нашем случае мы измеряем всю работу внутри GetWeather - по сути, разделяем время входящего спана на две части: overhead ASP.NET + наша бизнес-логика. В UI это выглядит как аккуратный вложенный блок внутри спана контроллера.
Однако не стоит плодить ручные спаны для каждого метода контроллера. Автоматические спаны по HTTP, БД и т.д. покрывают 80% потребностей. Ручные же спаны добавляют точечно: вокруг действительно долгих операций, внешних вызовов, обработки сообщений из очередей, сложных участков бизнес-логики.
Если вам нужно просто добавить дополнительные данные к текущему спану (например, записать какие-то параметры запроса), можно совсем не создавать новый спан. Достаточно вызвать:
Activity.Current?.SetTag("app.delayMs", delayMs);
Этот код повесит тег на текущий активный спан (который уже создан ASP.NET Core инструментацией). В результате тег появится, а лишний уровень вложенности - нет. Это часто лучший вариант, если вы хотите обогатить трейсы метаданными без излишней детализации.
В нашем примере я создал отдельный спан для демонстрации. Мы увидим его имя WeatherController.GetWeather в SigNoz и сможем по нему фильтровать, если нужно. Но на практике можно было обойтись и без него, ограничившись авто-спанами + парой тегов через Activity.Current?.SetTag() в контроллере.
Логи
Кроме трейсов и метрик, мы заодно настроили отправку логов через OpenTelemetry. В Program.cs для обоих сервисов есть такой код:
builder.Logging.AddOpenTelemetry(options=>
{
options.SetResourceBuilder(resourceBuilder);
options.IncludeFormattedMessage= true;
options.IncludeScopes= true;
options.ParseStateValues= true;
options.AddOtlpExporter();
});
Этим мы добавляем OTel-провайдер для ILogger. Теперь все логи, которые пишет приложение, тоже уходят по OTLP на Collector. В SigNoz они оказываются в разделе Logs. Мы уже видели, что при этом лог-записи несут TraceId и SpanId текущего контекста.
Пара настроек:
IncludeFormattedMessage = true- включает в логах итоговую отформатированную строку сообщения (а не только шаблон и параметры). Иначе увидите в поле Body что-то вродеRequest took {time} secondsбез подстановки реальных значений.IncludeScopes = true- если в .NET используются Scopes (контекст логгирования), они будут добавлены как атрибуты к записям. В ASP.NET Core много полезного кладётся в scoping (например, RequestId, ConnectionId), так что лучше включить.ParseStateValues = true- сериализует структурированные параметры ILogger (например, анонимные объекты) в атрибуты.
В итоге вместо традиционного вывода в консоль или файл, логи тоже летят в Collector вместе с трейсами. Поэтому что вы можете быстро найти нужный лог по тексту в SigNoz, посмотреть поле trace_id и сразу открыть соответствующий трейс с контекстом.
Корреляция логов и трейсов по trace_id - идея не новая: её давно делают через структурное логирование и добавление скоупа. Но OpenTelemetry стандартизировал это на уровне модели данных: у LogRecord есть поля TraceId/SpanId, поэтому логи и трейсы можно связывать единообразно. В .NET SDK корреляция включается автоматически: если во время записи лога есть активная Activity, SDK проставит TraceId/SpanId в лог-запись. В SigNoz это позволяет из лога сразу провалиться в соответствующий трейс.
Что можно улучшить, приближая решение к боевому продакшену
Если вы собираетесь внедрять похожее решение в реальный проект, учтите несколько моментов:
Включить сэмплинг трейсов 6. В продакшене нет смысла собирать абсолютно все трейсы - это слишком много данных. Обычно применяют сэмплирование по проценту (например, 5-10% случайных запросов) или по событию (ошибочные/долгие запросы всегда сохранять). OpenTelemetry позволяет задать простое сэмплирование через
OTEL_TRACES_SAMPLER=traceidratioиOTEL_TRACES_SAMPLER_ARG=<rate>или организовать более гибкий tail-based sampling на уровне Collector (когда решение о сохранении принимается уже после завершения трейса, с учётом его содержимого). Выбор зависит от требований, но 100% всегда включать не стоит - иначе у вас или кончатся ресурсы, или придётся хранить горы однотипных данных.Осторожнее с атрибутами и тегами. Не записывайте в трейс-атрибуты уникальные значения вроде идентификаторов пользователей, email и прочего. Иначе ваши хранилища (тот же ClickHouse) раздуются из-за бесконечного множества уникальных меток. Оставляйте в атрибутах то, по чему реально нужны агрегаты и фильтры: имя сервиса, тип запроса, статус, код ошибки.
Стандартизируйте имена кастомных спанов и меток. Если вы добавляете вручную спаны (через ActivitySource) или пользовательские теги, продумайте для них понятные имена и единообразный формат. Например, все свои спаны бизнес-логики можно называть с префиксом
BusinessLogic- тогда в UI можно одним фильтром найти все похожие операции. То же с тегами: вместо разнобоя вроде user, user_id, userId - выберите один стиль (можно опираться на семантические конвенции OpenTelemetry 1). Так поддерживаемость системы будет выше.Выносите общие теги в Middleware. Часто бывает, что каждый контроллер добавляет одни и те же теги (например, IP клиента и т.д.). Вместо копипаста в каждом контроллере - сделайте Middleware, который на начале запроса повесит нужные теги на Activity.Current. Тогда все трейсы будут обогащены этой информацией сразу и не придётся про это помнить при написании каждого нового endpoint'а.
Настройте фильтрацию чувствительных данных 5. Логи и трейсы могут содержать персональные данные или секреты. В продакшне крайне рекомендуется использовать фильтрацию, чтобы случайно не утекли пароли/API-ключи или пользовательские данные в систему мониторинга.
Итоги
Мы проделали путь от пустого проекта до полного стека наблюдаемости на базе OpenTelemetry. В локальном окружении развернулись два .NET-сервиса, база, коллектор и UI - и всё это заработало вместе. В результате мы можем в едином интерфейсе увидеть распределённые трейсы, DB-спаны, метрики и логи, которые можно связать с трейсами по идентификатору трассы.
При этом мы почти не трогали бизнес-логику и вся конфигурация укладывается в несколько понятных блоков в Program.cs. OpenTelemetry существенно упростил путь к Observability в экосистеме .NET: больше не нужно городить разрозненные инструменты для разных типов данных. Попробуйте подключить OTel в свой проект и, возможно, проблемы, на поиск которых раньше уходили часы, станут видны за пару минут.
Спасибо за внимание! Буду рад комментариям и вопросам. Исходный код примера доступен в репозитории OtelDotnetExample. Надеюсь, этот материал поможет вам сделать ваши сервисы более прозрачными и надёжными.
