Привет, Хабр!

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 - сгенерировать исключение для имитации ошибки


Дальше в статье рассмотрим:

  1. Как запустить всё это локально в Docker

  2. Где смотреть трейсы/метрики/логи в SigNoz

  3. Какие куски кода в .NET отвечают за отправку телеметрии

  4. Добавление дополнительных спанов и тегов


Быстрый старт: запускаем и собираем телеметрию

Предварительные требования: установлен 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 это позволяет из лога сразу провалиться в соответствующий трейс.


Что можно улучшить, приближая решение к боевому продакшену

Если вы собираетесь внедрять похожее решение в реальный проект, учтите несколько моментов:

  1. Включить сэмплинг трейсов 6. В продакшене нет смысла собирать абсолютно все трейсы - это слишком много данных. Обычно применяют сэмплирование по проценту (например, 5-10% случайных запросов) или по событию (ошибочные/долгие запросы всегда сохранять). OpenTelemetry позволяет задать простое сэмплирование через OTEL_TRACES_SAMPLER=traceidratio и OTEL_TRACES_SAMPLER_ARG=<rate> или организовать более гибкий tail-based sampling на уровне Collector (когда решение о сохранении принимается уже после завершения трейса, с учётом его содержимого). Выбор зависит от требований, но 100% всегда включать не стоит - иначе у вас или кончатся ресурсы, или придётся хранить горы однотипных данных.

  2. Осторожнее с атрибутами и тегами. Не записывайте в трейс-атрибуты уникальные значения вроде идентификаторов пользователей, email и прочего. Иначе ваши хранилища (тот же ClickHouse) раздуются из-за бесконечного множества уникальных меток. Оставляйте в атрибутах то, по чему реально нужны агрегаты и фильтры: имя сервиса, тип запроса, статус, код ошибки.

  3. Стандартизируйте имена кастомных спанов и меток. Если вы добавляете вручную спаны (через ActivitySource) или пользовательские теги, продумайте для них понятные имена и единообразный формат. Например, все свои спаны бизнес-логики можно называть с префиксом BusinessLogic- тогда в UI можно одним фильтром найти все похожие операции. То же с тегами: вместо разнобоя вроде user, user_id, userId - выберите один стиль (можно опираться на семантические конвенции OpenTelemetry 1). Так поддерживаемость системы будет выше.

  4. Выносите общие теги в Middleware. Часто бывает, что каждый контроллер добавляет одни и те же теги (например, IP клиента и т.д.). Вместо копипаста в каждом контроллере - сделайте Middleware, который на начале запроса повесит нужные теги на Activity.Current. Тогда все трейсы будут обогащены этой информацией сразу и не придётся про это помнить при написании каждого нового endpoint'а.

  5. Настройте фильтрацию чувствительных данных 5. Логи и трейсы могут содержать персональные данные или секреты. В продакшне крайне рекомендуется использовать фильтрацию, чтобы случайно не утекли пароли/API-ключи или пользовательские данные в систему мониторинга.


Итоги

Мы проделали путь от пустого проекта до полного стека наблюдаемости на базе OpenTelemetry. В локальном окружении развернулись два .NET-сервиса, база, коллектор и UI - и всё это заработало вместе. В результате мы можем в едином интерфейсе увидеть распределённые трейсы, DB-спаны, метрики и логи, которые можно связать с трейсами по идентификатору трассы.

При этом мы почти не трогали бизнес-логику и вся конфигурация укладывается в несколько понятных блоков в Program.cs. OpenTelemetry существенно упростил путь к Observability в экосистеме .NET: больше не нужно городить разрозненные инструменты для разных типов данных. Попробуйте подключить OTel в свой проект и, возможно, проблемы, на поиск которых раньше уходили часы, станут видны за пару минут.

Спасибо за внимание! Буду рад комментариям и вопросам. Исходный код примера доступен в репозитории OtelDotnetExample. Надеюсь, этот материал поможет вам сделать ваши сервисы более прозрачными и надёжными.