Существует множество возможных проблем приложений, которые удается заметить лишь с опозданием. Особенно когда релиз уже состоялся… К счастью, существует пара ключевых инструментов, которые выручают почти в любой ситуации, — и вряд ли найдется что-то проще.



Меня зовут Александр Пугач, я — Senior .NET Developer в проекте Data Warehouse «Лаборатории Касперского» (да-да, вы могли не знать, но у нас в компании широко используются .NET и «шарпы»).

В этой статье я расскажу, как работать с метриками в .NET на примере OpenTelemetry и Prometheus — систем, которые помогают отслеживать проблемы в работе приложений и быстро на них реагировать, обеспечивая стабильную и отказоустойчивую работу сервисов.

Когда-то метрики изменили мой процесс разработки, и теперь я надеюсь, что эта статья поможет вам перевернуть ваш взгляд на свои проекты.



Сначала расскажу, чем в принципе полезны метрики. Если вас здесь ничем не удивить, то сразу переходите к следующему разделу, где я разбираю OpenTelemetry и Prometheus и то, как добавить метрики в .NET-приложения.

И добавлю, что весь код, который показан в статье, доступен на GitHub по этой ссылке.

Что вы могли не знать про метрики...

Метрики


Как известно, метрики — это количественные данные, которые описывают производительность приложения или состояние системы. Если говорить о метриках в .NET, то обычно имеют в виду данные, собранные в ходе работы приложения, которые помогают отслеживать его производительность, статистику и другие аспекты. Это не просто числа и графики, они действительно помогают лучше понять, что происходит внутри приложения.

Примеры метрик:
  • RPS (requests per second) — одна из самых популярных метрик — количество запросов, обрабатываемых в секунду;
  • Latency (задержка) — время, прошедшее между отправкой запроса и получением ответа на него;
  • Throughput (пропускная способность) — количество данных, обрабатываемых в единицу времени;
  • Error rate (частота ошибок);
  • CPU usage (использование ЦПУ);
  • Memory usage (использование памяти);
  • и многое другое.

Чем вообще могут быть полезны метрики и мониторинг в целом:
  • выявление и устранение деградаций;
  • отслеживание производительности в режиме реального времени для последующей оптимизации (и повышения качества сервиса в целом);
  • раз уж я работаю в «Лаборатории Касперского», нельзя не вспомнить выявление аномалий работы в системе (связанных с попытками взлома) и повышение надежности и безопасности.

За более чем 20-летнюю историю .NET в этом фреймворке накопилось несколько API для метрик, которые до сих пор поддерживаются (https://learn.microsoft.com/en-us/dotnet/core/diagnostics/compare-metric-apis):
  • PerformanceCounter;
  • EventCounters;
  • System.Diagnostics.Metrics;
  • и сторонние API (AppMetrics, Prometheus — самые популярные).

Самый старый из них — PerformanceCounter — поддерживается только на Windows и предоставляет собой враппер над Windows Performance Counter. Именно с его помощью многие эксперты фреймворка .NET изучали его внутреннее устройство еще до того, как он стал опенсорсным. Он до сих пор поддерживается на всех версиях .NET (по крайней мере, так заявляет документация).

На смену PerformanceCounter пришел уже EventCounters (https://learn.microsoft.com/en-us/dotnet/core/diagnostics/compare-metric-apis). Задача его разработчиков была в том, чтобы создать легковесную кросс-платформенную приближенную к real time систему сбора метрик. Отчасти это получилось.



Но у EventCounters отсутствует строгая типизация (все либо object, либо string), отсутствует поддержка гистограмм и перцентилей, а также нет работы с многомерными метриками. Есть и другие ограничения. EventCounters доступен, начиная с .NET 3.1. Он напрямую поддерживается в Visual Studio, а также в dotnet-counter и в dotnet-monitor.

System.Diagnostics.Metrics — новый более функциональный API. В свое время с его помощью Microsoft совместно с комьюнити и OpenTelemetry попытались решить все накопленные проблемы. И отчасти это получилось.

System.Diagnostics.Metrics появился вместе с .NET 6.0, но при этом доступен и в более ранних версиях, поддерживает OpenTelemetry SDK и dotnet-counter. Здесь появилась поддержка гистограмм, строгая типизация и multi-dimensional metrics.



Этот проект все еще активно развивается, с момента релиза прошло не так много времени. Возможно, судить еще рано. Но на данный момент все выглядит достаточно перспективно.


OpenTelemetry


OpenTelemetry (https://opentelemetry.io/) — это программное обеспечение, которое включает в себя набор инструментов для сбора метрик, трассировок и логов в распределенных системах. Он был создан для обеспечения единого API и формата данных для сбора информации о приложениях на различных языках программирования и платформах.

OpenTelemetry — результат объединения в 2019 году двух открытых ранее и конкурировавших между собой стандартов:
  • OpenTracing, который появился в 2016 году;
  • OpenCensus от Google, который появился в 2018 году.

Сейчас это открытый свободный проект, который поддерживается сообществом разработчиков и такими корпорациями, как Microsoft, Google и Amazon.

OpenTelemetry работает через инструментирование кода. Приложение генерирует телеметрические данные, затем они экспортируются в бэкенд-сервисы для анализа и визуализации. Данные на бэкенд можно отправлять напрямую, а можно использовать протокол OpenTelemetry и специальный компонент OpenTelemetry Collector, который предоставляет независимую от вендора реализацию того, как получать, обрабатывать и экспортировать телеметрию. Еще он поддерживает большое количество опенсорс-форматов: Prometheus, Jaeger и т. п.


https://opentelemetry.io/docs/


На сегодняшний день почти любой уважающий себя вендор поддерживает протокол OpenTelemetry, поэтому его легко добавить в свою мониторинговую инфраструктуру. При этом данные можно собирать не только с собственных приложений, но и со сторонних систем, таких как базы данных, очереди и тому подобное. В целом это позволяет получить полную картину происходящего в системе и быстро выявлять проблемные места.

Чтобы понять, как вообще работает OpenTelemetry, обратимся к документации (https://opentelemetry.io/docs/reference/specification/overview/). Основные понятия описаны в спецификации сигнала.
  • Conventions — это определенный тип телеметрии — логи, метрики, трейсы.
  • API состоит из cross counting интерфейсов.
  • SDK — реализация API от команды OpenTelemetry.
  • Contrib — в этих пакетах реализована интеграция с популярными open-source-проектами для .NET. Также есть пакеты расширений для sdk и для API.
  • Instrumentation — пакеты, позволяющие сделать стороннюю библиотеку наблюдаемой через OpenTelemetry (они зависимы только от API, не от SDK).



Изначально в .NET добавили поддержку распределенного трейсинга. А вот метрики стали stable не так давно (https://github.com/open-telemetry/opentelemetry-dotnet). Однако их можно уже использовать в проде.



В целом видно, что проект активно развивается и комьюнити в этом участвует. Поэтому за обновлениями и изменениями стоит следить (https://github.com/orgs/open-telemetry/repositories?type=all&q=dotnet).

Примерно так выглядит иерархия объектов в .NET в namespace System.Diagnostics.Metrics:


https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md

В .NET этот namespace является частью фреймворка и состоит из основных элементов, таких как:
  • MeterProvider — входная точка API, которая предоставляет доступ к Meter;
  • Meter — класс, который отвечает за создание инструментов;
  • Instrument — инструменты, отвечающие непосредственно за репортинг измерений.

Instruments


Инструменты можно разделить на синхронные и асинхронные (https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md).

Синхронные предназначены для вызова в рамках логики и обработки приложений:
  • Counter;
  • Histogram;
  • UpDownCounter.

Асинхронные дают пользователю возможность зарегистрировать callback-функцию, которая потом будет вызвана по требованию:
  • ObservableCounter;
  • ObservableGauge;
  • ObservableUpDownCounter.

Рассмотрим эти инструменты подробнее.

Counter — синхронный инструмент. Он поддерживает неотрицательные инкременты, например можно считать количество запросов, полученных HTTP-клиентом, либо количество обработанных им байтов:



А ObservableCounter — это тот же самый Counter, только уже асинхронный, для которого нужно определять callback-функцию. Так можно подсчитывать CPU Time либо аптайм приложения.



ObservableGauge — это асинхронный инструмент, который возвращает напрямую измеряемое значение. Это может быть memory usage. А вот Histogram — это уже синхронные инструмент, который измеряет распределение значений в заданном диапазоне. Например, если мы измеряем время обработки запросов, на горизонтальной оси гистограммы будут указаны бакеты, а на вертикальной — время обработки (сверху — количество измерений, попавших в определенный бакет).

UpDownCounter — синхронный инструмент, который поддерживает инкременты и декременты, например количество активных реквестов.



ObservableUpDownCounter — от же самый UpDownCounter, только асинхронный. Для него нужно определять callback-функцию. Таким образом удобно измерять, например, размер очереди.

С точки зрения Prometheus все эти шесть инструментов так или иначе сводятся к трем типам метрик:
  • Counter;
  • Gauge;
  • Histogram.

Поэтому в OpenTelemetry будет удобно использовать:
  • Counter;
  • ObservableGauge;
  • Histogram.



Еще три инструмента, конечно, добавляют разнообразия, но функционально сильно пересекаются с этим списком, поэтому, используя их, слишком легко запутаться. Также хочу заметить, что несмотря на то что использовать ObservableGauge не всегда удобно, здесь нет знакомого по другим библиотекам синхронного Gauge, для которого не нужно определять callback-функцию. В теории это можно решить, сделав на основе ObservableGauge самый обычный синхронный Gauge. Далее я покажу, как это сделать.

Но перейдем к коду.
Для начала создадим meter, укажем ему имя и версию. Как правило, на одну библиотеку создается один meter. Далее с помощью meter создаем counter:

var meter = new Meter(“MyLibrary”, “1.0”);

var counter = meter.CreateCounter<long>(
	“counter.name”,
unit: “things”,
description: “A count of things”);

Передаем как дженерик параметр long — это тип измеряемого значения. Также передаем:
  • имя — по соглашению в OpenTelemetry основное имя должно разделяться точкой;
  • unit — это единицы измерения;
  • description — описание в свободной форме.

После этого необходимо зарегистрировать этот meter в MeterProviderBuilder. Укажем тип Exporter, для примера укажем консоль:

using var meterProvider = Sdk.CreateMeterProviderBuilder()
	.AddMeter(meter.Name)
	.AddConsoleExporter()
	.Build();

while (!Console.KeyAvailable)
{
	counter.Add(200, new KeyValuePair<string, object?>(“tag2”, “value2”));
}

Здесь мы в цикле задали для примера значение 200 и также передали KeyValuePair — это так называемый тег лейбл, т. е. ключ-значение, с помощью которых мы обогащаем метрики полезной информацией. В будущем она может пригодиться.

После запуска приложения в консоли это будет выглядеть следующим образом:



Здесь видна различная метаинформация о метрике — description, unit, имя, временные отметки и, самое главное, — значение. Видно, что это значение монотонно возрастает.

Еще один популярный и удобный инструмент — ObservableGauge. Он асинхронный, поэтому ему нужно определять callback-функцию.

var observableGauge = meter.CreateObservableGauge(
    "observable.gauge.name",
    () => new Measurement<long>(
        Random.Shared.Next(),
        new KeyValuePair<string, object?>("tag3", "value3")),
    unit: "unit",
    description: "Random.Shared.Next()");


Тут мы также передаем имя. Callback-функция должна возвращать тип Measurement. Для примера пусть это будет рандомное значение типа long. Так же, как и в синхронных инструментах, можно задать тег — тоже ключ-значение — unit и description.

После запуска в консоли мы увидим следующее:



По метаинформации все то же самое. И видно, что значение меняется рандомно.

Последнее — это гистограмма. Это синхронный инструмент, поэтому сигнатуры методов похожи на каунтер.

var histogram = meter.CreateHistogram<long>(
    "histogram.name",
    unit: "ms",
    description: "Random.Shared.Next(0, 1000)");

while (!Console.KeyAvailable)
{
    histogram.Record(
        Random.Shared.Next(0, 1000),
        new KeyValuePair<string, object?>("tag4", "value4"));
}


Здесь создаем гистограмму — указываем имя, unit, description. В цикле вызываем метод Record и возвращаем рандомное значение от 0 до 1000. Дополнительно передаем тег.

После запуска в консоли все это будет выглядеть следующим образом:



Здесь отображается уже не одно значение, а распределение по бакетам. По умолчанию так выглядит набор бакетов от 0 до 1000. Понятно, что такой набор не всем подходит, его можно задавать в конфигурации. Здесь также добавляются метрики: Sum — сумма всех измерений и Count — количество измерений с начала запуска приложения.

Понятно, что никто метрики в консоли смотреть не будет. Этот вариант лучше подходит для локального тестирования. Поэтому перейду к такому инструменту, как Prometheus.

Prometheus


Это опенсорсная система мониторинга и алертинга, которая позволяет собирать метрики из различных источников, визуализировать и анализировать их в удобном формате (https://prometheus.io/).

Вкратце пройдусь по основным компонентам Prometheus (https://prometheus.io/docs/introduction/overview/).

В основе лежит Prometheus-сервер, который сохраняет значение метрик в time-series базы данных. Prometheus работает по pull-модели, поэтому Retrieval-воркеры ходят на определенные таргеты, которые возвращают значения в Prometheus-формате. Но использование pull-модели не всегда удобно, поэтому Prometheus предоставляет возможность работы по push-модели, но для этого нужно использовать специальный компонент — Pushgateway. По push-модели удобно работать, например, с какими-то короткоживущими джобами, которые выполняют работу, отправляют метрику и умирают. Джобы будут ходить в Gateway по push-модели, но Prometheus к этому Gateway будет обращаться по pull-модели.

Также у Prometheus есть HTTP API, с помощью которого можно забирать метрики из сторонних систем. Для этого нужно использовать язык запросов PromQL, созданный специально для Prometheus. А еще есть Alertmanager, который используется для обработки и роутинга алертов. Он предоставляет большое количество форматов нотификации — электронная почта, мессенджеры и т. п.



Обычно под Prometheus подразумевается не только сам сервер, но и набор всех этих компонентов — Pushgateway, Alertmanager, Web-UI.

Почему Prometheus?


Не буду сравнивать Prometheus с другими инструментами. Просто расскажу, почему он мне нравится.
  • Благодаря своей архитектуре Prometheus — один из наиболее легких инструментов в установке и настройке.
  • Гибкий язык запросов PromQL позволяет агрегировать метрики, его удобно использовать в достаточно больших сценариях.
  • Prometheus позволяет масштабироваться с использованием дополнительных инструментов (Thanos, Victoria Metrics, Cortex). Например, у себя мы используем Victoria Metrics.
  • У Prometheus открытый исходный код — его можно свободно использовать, модифицировать и распространять.
  • У Prometheus есть очень широкая поддержка от сообщества разработчиков.
  • Также Prometheus имеет множество различных интеграций со сторонними системами, в том числе с Kubernetes. Доступно много примеров кода с хорошей документацией.

Как добавить Prometheus Exporter в приложение


Самый простой способ — добавить его как HTTP listener. Реализуем это на примере консольного приложения из предыдущих разделов. Для этого нужно указать URL, на котором он будет развернут, и эндпоинт, где будут доступны метрики. То есть нам не надо менять код, ответственный за заполнение метрик, а лишь поменять тип exporter-а. Для этого нужно подключить nuget-пакет OpenTelemetry.Exporter.Prometheus.HttpListener.

using var meterProvider = Sdk.CreateMeterProviderBuilder()
	.AddMeter(meter.Name)
	.AddPrometheusExporter(opt =>
	{
		opt.UriPrefixes = new[] { "http://localhost:5000/" };
		pt.ScrapeEndpointPath = "/metrics";
	})
	.Build();

Теперь после запуска приложения мы увидим метрики в Prometheus-формате:


https://prometheus.io/docs/introduction/overview/


Точки в именах метрик заменяются на нижние подчеркивания, а в конец имени метрики добавляется unit. В фигурных скобках отображаются теги — ключ-значения, которые мы задаем. После фигурных скобок идет непосредственно значение метрики и временная отметка, когда она была собрана. Также добавляются дополнительные атрибуты. Именно сюда переходят заданные ранее description и type — counter или gauge.

Для гистограммы все выглядит несколько иначе:



Для каждого бакета создается отдельная метрика с тегом le, отвечающим за значение бакета (это и есть количество измерений, которое меньше или равно значению бакета). Также добавляются метрики суммы — общая сумма и количество всех измерений.

Сервер Prometheus должен обращаться к эндпоинту, который отдает ему метрики по HTTP в определенном формате, а затем сохраняет это в свою тайм-серию в базе данных.

OpenTelemetry можно интегрировать и с ASP.NET Core-приложениями.
Для этого необходимо создать ApplicationBuilder, meter и добавить OpenTelemetry в DI и подключить nuget пакеты OpenTelemetry.Extensions.Hosting, OpenTelemetry.Exporter.Prometheus.AspNetCore.

var builder = WebApplication.CreateBuilder(args); 

var meter = new Meter("MyLibrary", "1.0");
builder.Services.AddOpenTelemetry()
    .WithMetrics(builder =>
    {
        builder
            .AddMeter(meter.Name)
            .AddPrometheusExporter();
    });

var app = builder.Build();


После этого с помощью meter создаем Counter и указываем этот эндпоинт. Counter будет отвечать за расчет количества запросов, которые попали на endpoint Hello.

var counter = meter.CreateCounter<long>(
    "hello.requests.count",
    description: "The number of hello requests");

app.MapGet("/hello", async () =>
{
    counter.Add(1);
    await Task.Delay(Random.Shared.Next(1000));
    return "Hello, World!";
});

app.UseOpenTelemetryPrometheusScrapingEndpoint("metrics");
app.Run();

Здесь мы попадаем на эндпоинт и увеличиваем значение counter на единицу. А потом делаем задержку для имитации какой-то деятельности и возвращаем Hello World! Также нужно обязательно зарегистрировать middleware Prometheus, где будут доступны метрики в соответствующем формате.

После запуска приложения можно увидеть эту метрику в формате Prometheus (hello_requests_count, который мы задавали в приложении):



Custom Instruments


Чтобы метрики не выглядели так скудно, на помощь приходят дополнительные инструменты. Как обычно, можно найти готовые решения или написать что-то самому.

Для начала поговорим о готовых инструментах.
  • opentelemetry-dotnet (https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/src) — первый репозиторий, куда стоит посмотреть. Тут есть несколько полезных библиотек, если, например, вы используете ASP.NET Core- или gRPC-приложения, или же в приложении применяете HTTP- или SQL-клиент. Пакеты из этого репозитория помогут обогатить приложение дополнительными метриками буквально за одну строчку кода.
  • opentelemetry-dotnet-contrib (https://github.com/open-telemetry/opentelemetry-dotnet-contrib/tree/main/src) — второй источник готовых инструментов. Содержит большое количество библиотек для популярных проектов, в основном опенсорс. Рекомендую, однако, проверять поддержку метрик — распределенные трейсы были добавлены раньше и не все библиотеки реализовали их поддержку метрик. Еще нужно помнить, что много полезной информации есть в EventCounters. Поэтому сделали специальную библиотеку OpenTelemetry.Instrumentation.EventCounters, которая позволяет прокинуть метрики из EventCounters сразу в OpenTelemetry.

DiagnosticSource и DiagnosticListener


Еще пара инструментов для обогащения телеметрических данных — DiagnosticSource и DiagnosticListener.

System.Diagnostics.DiagnosticSource — это набор API, который позволяет с одной стороны отправлять именные события, а со стороны приложения подписываться на них и выполнять некоторые обработки.

Вот как это реализовано в библиотеке OpenTelemetry.Instrumentation.AspNetCore (https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/src/OpenTelemetry.Instrumentation.AspNetCore):

internal AspNetCoreMetrics(AspNetCoreMectricsInstrumentationOptions options)
{
	Guard.ThrowIfNull(options);
	this.meter = new Meter(InstrumentationName, InstrumentationVersion);
	var metricsListener = new HttpInMetricsListener(“Microsoft.AspNetCore”, this.meter. options);
	this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber(metricsListener, this.isEnabled);
	this.diagnosticSourceSubscriber.Subscribe();
}

Здесь создается HTTP-listener, который подписывается на события Microsoft.AspNetCore и с помощью создаваемого meter пробрасывает эти события в OpenTelemetry. Таким образом, если метрик, которые нам нужны, нет, их всегда можно добавить самим.

Добавление метрик в приложение


Рассмотрим пример, как можно добавить метрику по аптайму приложения. Я не нашел ее в готовых библиотеках, но мне она кажется полезной на дашборде.

Аптайм мы будем рассчитывать как текущее время минус время старта процесса через метод GetUpTime.

public class UpTimeProvider
{
    private static readonly DateTime StartTime;

    static UpTimeProvider()
    {
        using var process = Process.GetCurrentProcess();
        StartTime = process.StartTime;
    }

    public static TimeSpan GetUpTime() => DateTime.Now - StartTime;
}


Для этого создадим класс UpTimeProvider. В конструкторе сохраним время старта процесса, которое далее будет использоваться в методе для расчета.

Также потребуется класс UpTimeMetrics, который будет отвечать за взаимодействие с OpenTelemetry и за создание Meter.

В конструкторе мы создаем метр, указываем имя и версию. Имя нужно сделать public-полем, чтобы можно было извне зарегистрировать это Meter в MeterProvider. Непосредственно с помощью Meter создаем ObservableCounter. Этот инструмент хорошо подходит для такого типа метрик, как UpTime (монотонно возрастающее значение).

internal sealed class UpTimeMetrics : IDisposable
{
    internal static readonly string InstrumentationName = typeof(UpTimeMetrics).FullName!;
    internal static readonly string? InstrumentationVersion 
        = typeof(UpTimeMetrics).Assembly.GetName().Version?.ToString();

    private readonly Meter _meter;

    public UpTimeMetrics()
    {
        _meter = new Meter(InstrumentationName, InstrumentationVersion);
        var _ = _meter.CreateObservableCounter(
            "application.uptime",
            () => (long)UpTimeProvider.GetUpTime().TotalMilliseconds,
            unit: "ms",
            description: "Milliseconds elapsed since application startup");
    }

    public void Dispose() => _meter.Dispose();
}


Здесь мы задали имя и callback-функцию, которая будет возвращать из провайдера значение в миллисекундах, указали unit — миллисекунды — и description.

Также понадобилось реализовать интерфейс IDisposable. Поскольку meter этот интерфейс реализует, по всем канонам мы должны его диспозить.

Остается добавить метод расширения для MeterProviderBuilder.

public static class MeterProviderBuildExtensions
{
    public static MeterProviderBuilder AddUpTimeInstrumentation(
        this MeterProviderBuilder builder)
    {
        builder.AddMeter(UpTimeMetrics.InstrumentationName);
        builder.AddInstrumentation(new UpTimeMetrics());

        return builder;
    }
}

Здесь мы вызываем метод AddMeter, в который передаем значение метра (internal поля). Далее вызываем метод AddInstrumentation, в который передаем UpTimeMetrics. После этого в течение жизни этого класса данный метод будет отвечать за OpenTelemetry (и за создание, и за диспозинг).

Вот так выглядит наша метрика. Ее значение увеличивается монотонно при обновлении — это вполне ожидаемо.



Application instrumentation


Поговорим о том, как инструментировать приложения и непосредственно бизнес-логику. Самый простой способ это реализовать — через прямые вызовы.

Прямые вызовы


Представим, что у нас есть демоприложение, которое обрабатывает некие сообщения и сохраняет их во внутреннее хранилище. Определим для них простейшие абстракции, например интерфейс IEntity и IMessage, которые декларируют обязательные поля для entity и message, соответственно:

public interface IEntity
{
    public long Id { get; set; }
}

public interface IMessage
{
    public string MessageId { get; set; }
    public DateTime TimestampUtc { get; set; }
}

Для примера создадим две entity:

public class User : IEntity
{
    public long Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
}
public class Order : IEntity
{
    public long Id { get; set; }
    public string ProductName { get; set; }
    public decimal Price { get; set; }
    public DateTime Date { get; set; }
}

Из абстракций нужен IMessageHandler, который получает сообщения на входе и каким-то образом их обрабатывает:

public interface IMessageHandler<TMessage>
    where TMessage : IMessage
{
    Task Handle(TMessage message, CancellationToken ct);
}

Также есть IMessageWorker, который запускает обработку определенных типов сообщений:

public interface IMessageWorker<TMessage, TEntity> where TEntity : IEntity where TMessage : IMessage
{
    Task RunAsync(CancellationToken ct);
}

И репозиторий, который сохраняет значение в некое внутреннее хранилище:

public interface IRepository<TEntity> where TEntity : IEntity
{
    Task MergeEntity(TEntity entity, CancellationToken ct);
}

А так при этом может выглядеть простейшая реализация MessageHandler:

public class MessageHandler<TMessage, TEntity> : IMessageHandler<TMessage>
    where TEntity : IEntity
    where TMessage : IMessage
{
    private readonly IRepository<TEntity> _repository;
    private readonly IMapper _mapper;
    private readonly ILogger<MessageHandler<TMessage, TEntity>> _logger;

    public MessageHandler(IRepository<TEntity> repository, IMapper mapper,
        ILogger<MessageHandler<TMessage, TEntity>> logger)
    {
        _repository = repository;
        _mapper = mapper;
        _logger = logger;
    }

    public async Task Handle(TMessage message, CancellationToken ct)
    {
        _logger.LogInformation($"Handle message {typeof(TMessage).Name}");
        var entity = _mapper.Map<TEntity>(message);
        await _repository.MergeEntity(entity, ct);
    }
}

Здесь мы в конструкторе принимаем все необходимые объекты для работы приложения — репозиторий, маппер, логгер — сохраняем их в поля readonly и реализуем метод handle, который сначала логгирует полученное сообщение, а потом делает mapping. В результате мы получаем entity и уже его сохраняем во внутреннее хранилище.

Первый вариант обогатить метриками приложение — использовать прямые вызовы непосредственно в бизнес-логике. Но прежде чем переходить к ним, рассмотрим класс OtelMetrics, который будет отвечать за взаимодействие с OpenTelemetry и создание Meter.

public class OtelMetrics : IDisposable
{
    public static readonly string MeterName = typeof(OtelMetrics).FullName!;
    private readonly Meter _meter;

    public OtelMetrics()
    {
        var version = typeof(OtelMetrics).Assembly.GetName().Version?.ToString();
        _meter = new Meter(MeterName, version);

        HandlerSuccessCounter = _meter.CreateCounter<long>(
            "handler.success", "count", "The number of handler success execution");

        HandlerFailCounter = _meter.CreateCounter<long>(
            "handler.fail", "count", "The number of handler fail execution");

        HandlerLatencyHistogram = _meter.CreateHistogram<long>(
            "handler.latency", "ms", "Message latency");

        HandlerDurationGauge = new Gauge<long>(
            _meter, "handler.duration", "ms", "Handle duration milliseconds");
    }

    public Counter<long> HandlerSuccessCounter { get; }
    public Counter<long> HandlerFailCounter { get; }
    public Histogram<long> HandlerLatencyHistogram { get; }
    public Gauge<long> HandlerDurationGauge { get; }

    public void Dispose() => _meter.Dispose();
}

Здесь также IDisposable, поскольку мы создаем meter и диспозить также должны.
В итоге мы создаем meter, передаем ему имя (вместе с версией берем его из assembly) и делаем его публичным полем. Далее создаем те метрики и те инструменты, которыми хотим обогатить приложение:
  • HandlerSuccessCounter отвечает за количество успешных обработок handler;
  • HandlerFailCounter отвечает за количество неуспешных обработок;
  • HandlerLatencyHistogram считает задержку как разницу текущего времени и времени публикации события в очередь;
  • HandlerDurationGauge отвечает за расчет времени выполнения метода Handle. Видно, что для этого используется стандартный metrics API, но класс называется Gauge — это тот класс, который позволяет из асинхронного ObservableGauge сделать синхронный Gauge. Вот как выглядит этот класс:

public class Gauge<T> where T : struct
{
    private readonly object _lock = new();
    private readonly Dictionary<string, Measurement<T>> _measurements = new();

    public Gauge(Meter meter, string name, string? unit = null, string? description = null)
    {
        meter.CreateObservableGauge(name, () => _measurements.Values, unit, description);
    }

    public void SetValue(T value, KeyValuePair<string, object?>? tag = null)
    {
        lock (_lock)
        {
            var key = tag.ToString() ?? string.Empty;
            var tags = tag is null ?
                    Array.Empty<KeyValuePair<string, object?>>() :
                    new[] { tag.Value };

            if (_measurements.ContainsKey(key))
                _measurements[key] = new (value, tags);
            else
                _measurements.Add(key, new(value, tags));
        }
    }
}

Здесь в конструкторе мы принимаем meter, имя, unit и description, чтобы создать Gauge. После этого сразу вызываем CreateObservableGauge и в качестве callback-функции возвращаем значение из словаря. А словарь мы заполняем в методе SetValue, который принимает значение для метрики и тег (KeyValuePair). Далее мы рассчитываем ключ для словаря: если ключ найден, изменяем значение по ключу, если нет, то добавляем новое значение в словарь.

Вернемся к приложению. Рассмотрим класс MessageHandlerWithMetrics, он реализует интерфейс IMessageHandler. Принимает в репозитории все те же объекты плюс otelMetrics и сохраняет все это в публичные поля.

public MessageHandlerWithMetrics(IRepository<TEntity> repository, IMapper mapper,
    ILogger<MessageHandlerWithMetrics<TMessage, TEntity>> logger, OtelMetrics otelMetrics)
{
    _repository = repository;
    _mapper = mapper;
    _logger = logger;
    _otelMetrics = otelMetrics;
    _tag = new KeyValuePair<string, object?>("message", typeof(TMessage).Name);
    _otelMetrics.HandlerSuccessCounter.Add(0, _tag);
    _otelMetrics.HandlerFailCounter.Add(0, _tag);
}

Здесь мы задаем тег сразу же (key — сообщение, а value — имя сообщения), так как handler может обрабатывать разные типы сообщений и удобно смотреть на метрики в разрезе этих типов. Далее инициализируем каунтеры нулями, чтобы, даже если handler еще не начал обрабатывать сообщения, метрика уже была, — это нужно просто для удобства.

Метод Handle выглядит следующим образом:

public async Task Handle(TMessage message, CancellationToken ct)
{
    var stopwatch = Stopwatch.StartNew();
    try
    {
        _logger.LogInformation($"Handle message {typeof(TMessage).Name}");
        var entity = _mapper.Map<TEntity>(message);
        await _repository.MergeEntity(entity, ct);

        _otelMetrics.HandlerSuccessCounter.Add(1, _tag);
        _otelMetrics.HandlerLatencyHistogram.Record(
            (long)(DateTime.UtcNow - message.TimestampUtc).TotalMilliseconds, _tag);
    }
    catch (Exception)
    {
        _otelMetrics.HandlerFailCounter.Add(1, _tag);
        throw;
    }
    finally
    {
        _otelMetrics.HandlerDurationGauge.SetValue(stopwatch.ElapsedMilliseconds, _tag);
    }
}

Здесь мы сразу создаем stopwatch, который пригодится для расчета duration. Также добавляем блок try-catch-finally, в котором остается три строчки бизнес-логики без изменений. Если бизнес-логика отработала успешно, увеличиваем SuccessCounter на единицу и рассчитываем Latency (как текущее время минус время публикации сообщения в очередь) — добавляем ее в гистограмму. В catch-блоке увеличиваем FailCounter и дальше пробрасываем Exception. А в finally мы задаем значение для duration и берем его из stopwatch.

Декораторы


Если посмотреть на реализованный метод, сразу видно, что в нем бизнес-логика перемешивается с каким-то инфраструктурным кодом по метрикам. Это не очень хорошо, так как отвлекает от основной логики, которую должен выполнять handler. Для решения этой проблемы нам на помощь придет декоратор. Это структурный паттерн проектирования, который позволяет динамически добавлять функциональность объектам.

Вот как это может выглядеть в нашем случае. Класс MonitoredMessageHandler должен реализовывать интерфейс IMessageHandler, принимая в конструкторе messageHandler и otelMetrics для взаимодействия с метриками.

public class MonitoredMessageHandler<TMessage> : IMessageHandler<TMessage>
    where TMessage : IMessage
{
    private readonly IMessageHandler<TMessage> _messageHandler;
    private readonly OtelMetrics _otelMetrics;
    private readonly KeyValuePair<string, object?> _tag;

    public MonitoredMessageHandler(IMessageHandler<TMessage> messageHandler,
        OtelMetrics otelMetrics)
    {
        _messageHandler = messageHandler;
        _otelMetrics = otelMetrics;
        _tag = new KeyValuePair<string, object?>("message", typeof(TMessage).Name);

        _otelMetrics.HandlerSuccessCounter.Add(0, _tag);
        _otelMetrics.HandlerFailCounter.Add(0, _tag);
    }

    public async Task Handle(TMessage message, CancellationToken ct)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            await _messageHandler.Handle(message, ct);
            _otelMetrics.HandlerSuccessCounter.Add(1, _tag);

            _otelMetrics.HandlerLatencyHistogram.Record(
                (long)(DateTime.UtcNow - message.TimestampUtc).TotalMilliseconds, _tag);
        }
        catch (Exception)
        {
            _otelMetrics.HandlerFailCounter.Add(1, _tag);
            throw;
        }
        finally
        {
            _otelMetrics.HandlerDurationGauge.SetValue(stopwatch.ElapsedMilliseconds, _tag);
        }
    }
}

Как и в предыдущем примере, мы сохраняем handler, создаем тег, инициализируем каунтеры. Но сам метод Handle будет выглядеть немного иначе. Инфраструктурный код для метрик при этом остается без изменений, а вызов бизнес-логики заменяется на один вызов handle у MessageHandler.

Теперь нам остается только зарегистрировать декоратор в DI с помощью метода Decorate, который предоставляет библиотека Scrutor.

public static IServiceCollection AddMonitoring(this IServiceCollection services)
{
	services.Decorate(typeof(IMessageWorker<,>), typeof(MonitoredMessageWorker<,>));

	services.Decorate(
		typeof(IMessageHandler<>), 
		typeof(MonitoredMessageHandler<>));

	return services;
	}
}

Использование декораторов — дискуссионный вопрос. Кому-то нравится, кому-то нет. Именно поэтому я показал разные варианты — каждый может для себя решать, что ему использовать. Мы в команде активно используем декораторы для метрик — это удобно.

HealthChecks


Еще один важный момент настройки мониторинга приложений — это Health Check (https://learn.microsoft.com/en-US/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-7.0). HealthCheck API в .NET позволяет создавать отчеты работоспособности приложения — как внутренних, так и внешних его частей или даже физических компонентов сервера. Например, можно проверять CPU, память, внешние зависимости, очереди в HTTP сервис и тому подобное. И это можно использовать в различных сценариях, например для балансировки нагрузки или для настройки алертов.

Покажу, как работает Health Check API, на примере.
Создаем Web Application Builder, вызываем метод AddHealthChecks и мапим middleware Health, где будут доступны результаты healthcheck.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks(“/health”);
app.Run();

Добавим простейший healthcheck. Для этого нужно реализовать класс с интерфейсом IHealthCheck. У него всего один метод, который возвращает HealthCheckResult, принимая HealthCheckContext и CancellationToken:

public class SampleHealthCheck : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        var isHealthy = Random.Shared.Next(2) == 1;

        if (isHealthy)
            return Task.FromResult(HealthCheckResult.Healthy("A healthy result."));

        return Task.FromResult(
            new HealthCheckResult(context.Registration.FailureStatus,
            "An unhealthy result."));
    }
}	

Здесь для примера я реализовал healthcheck, который возвращает рандомное значение. Если оно true, возвращаем Healthy, а если false, то FailureStatus из контекста.

После этого остается зарегистрировать наш healthcheck в DI. Для этого вызовем метод AddHealthChecks, добавив имя:

Program.cs

services
	.AddHealthChecks()
	.AddCheck<SampleHealthCheck>(“sample”);

Теперь после запуска приложения мы можем проверять статус наших healthcheck на эндпоинте Health. К сожалению, у healthcheck нет встроенной интеграции с OpenTelemetry. Если мы хотим пробрасывать результаты healthcheck в метрики OpenTelemetry, это придется сделать самим. К счастью, сделать это не так сложно.

Надо создать необходимые инструменты — HealthCheckGauge, который будет отвечать за результат определенного healthcheck, и HealthGauge, который будет отвечать за общее здоровье сервиса (рассчитываться по результатам всех healthcheck).

OtelMetrics.cs

HealthCheckGauge = new Gauge<long>(
	_meter, “health.check”, description: “Health check status”);

HealthGauge = new Gauge<long>(
	_meter, “health”, description: “General application health status”);

Воспользуемся таким инструментом, как IHealthCheckPublisher, — интерфейсом, который при добавлении в DI периодически запускает healthcheck-и и публикует результаты этих проверок в метод PublishAsync. В HealthReport оказывается вся информация о проверках, которые были пройдены. Поэтому в конструкторе мы принимаем OtelMetrics и реализуем PublishAsync.

public class MetricsHealthCheckPublisher : IHealthCheckPublisher
{
    private readonly OtelMetrics _otelMetrics;
    public MetricsHealthCheckPublisher(OtelMetrics otelMetrics) => _otelMetrics = otelMetrics;

    public Task PublishAsync(HealthReport report, CancellationToken cancellationToken)
    {
        foreach (var entry in report.Entries)
        {
            var tag = new KeyValuePair<string, object?>("name", entry.Key);
            _otelMetrics.HealthCheckGauge.SetValue((long)entry.Value.Status, tag);
        }

        _otelMetrics.HealthGauge.SetValue((long)report.Status);

        return Task.CompletedTask;
    }
}

Здесь мы проходим в цикле по всем healthcheck-ам и для каждого создаем тег с именем и задаем значение. После цикла задаем значение HealthGauge, отвечающее за общее здоровье сервиса.
В итоге остается только зарегистрировать HealthCheckPublisher в DI.

services
	.AddHealthChecks()
	.AddCheck<SampleHealthCheck>(“sample”);

services.AddSingleton<
	IHealthCheckPublisher,
	MetricsHealthCheckPublisher>();

После запуска приложения в метриках можно будет увидеть результаты healthcheck-ов.



OpenTelemetry Colleсtor


Выше я говорил про прямую отправку телеметрических данных в бэкенд-сервис (в нашем случае в Prometheus). Но это не единственный вариант архитектуры. Есть альтернатива — OpenTelemetry Colleсtor (https://opentelemetry.io/docs/collector/). Это вендор-агностик-решение для получения, обработки и экспорта телеметрических данных.

OpenTelemetry Collector не ограничивается метриками. Он умеет работать и с логами, и с трейсами. Это устраняет необходимость использования нескольких объектов для каждого типа телеметрии. Кроме того, OpenTelemetry Colleсtor поддерживает большое количество форматов с открытым исходным кодом — Jaeger, Prometheus и т. п.



OpenTelemetry Collector — это единый инструмент для всех типов данных. Он создавался с целью:
  • создать гибкую конфигурацию по умолчанию;
  • обеспечить поддержку популярных протоколов;
  • быстро настраивать и запускать его из коробки;
  • сохранить высокий performance, то есть стабильность и производительность на различных конфигурациях;
  • дать разработчикам расширяемость, чтобы они могли писать кастомные решения под инструмент.

Предлагаю рассмотреть пример, как приложение отправляет телеметрические данные в OpenTelemetry Collector по OpenTelemetry-протоколу. Обработки никакой не будет — полученные данные мы отправим в Exporter в Prometheus-формате (Prometheus будет ходить в OpenTelemetry Collector по pull-схеме).

Посмотрим, как может выглядеть простейшая конфигурация. Для OpenTelemetry Collector она определяется в yaml-формате.

Файл otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:

exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"
    metric_expiration: 5m
    const_labels:
      env: production

extensions:
  health_check:

service:
  extensions: [health_check]
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [prometheus]

Здесь мы создали ресивер, который будет работать по протоколу OpenTelemetry и использовать gRPC-протокол. HTTP-протокол тоже поддерживается. Определять процессоры нам не надо, поэтому определяем экспортер в Prometheus-формате, который будет доступен на эндпоинте на порту 8889. Задаем время экспирации метрик — 5 минут, и для примера константные лейблы: добавим лейбл env со значением production.
В разделе service мы можем определить pipeline для обработки определенного типа телеметрии. Здесь мы определили его для метрик — метрики попадают в ресивер, который работает в otlp, а потом в exporter prometheus без какой-то обработки.

С помощью docker-compose-файла мы можем просто запустить OpenTelemetry Collector с этой конфигурацией. Откроем ему порт 8889, который мы указали для Prometheus, а также порт 4317, чтобы принимать метрики по gRPC-протоколу.

Файл docker-compose.yaml
version: “3.7”
services:
  otel-collector:
    image: otel/opentelemetry-collector:0.77.0
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "8889:8889"   # Prometheus exporter metrics http://localhost:8889/metrics
      - "4317:4317"   # OTLP gRPC receiver

Добавим все это в наше приложение.
Вместо Prometheus exporter добавляем OpenTelemetry exporter, указываем эндпоинт, где будут публиковаться метрики, и подключаем nuget-пакет OpenTelemetry.Exporter.OpenTelemetryProtocol:

builder.Services.AddOpenTelemetry()
    .WithMetrics(builder =>
    {
        builder
            .SetResourceBuilder(ResourceBuilderHelper.CreateResourceBuilder(typeof(Program)))
            .AddMeter(OtelMetrics.MeterName)
            .AddInstrumentation<OtelMetrics>()
            .AddUpTimeInstrumentation()
            .AddOtlpExporter(op =>
            {
                op.Endpoint = new Uri("http://localhost:4317");
            });
    });

Можно заметить, что здесь вызывается метод SetResourceBuilder. Это такой механизм (контейнер), который хранит дополнительные метаданные для exporter-а. Prometheus exporter не поддерживает никакие данные из Resource Builder, а вот OpenTelemetry exporter как раз поддерживает эти данные.

Посмотрим, каким образом он это делает. Для этого рассмотрим метод CreateResourceBuilder.

public static ResourceBuilder CreateResourceBuilder(Type assemblyTag)
{
    var assembly = Assembly.GetAssembly(assemblyTag)!.GetName();
    var resourceBuilder = ResourceBuilder
            .CreateDefault()
            .AddService(
                serviceName: assembly.Name!,
                serviceVersion: assembly.Version?.ToString(),
                autoGenerateServiceInstanceId: false,
                serviceInstanceId: Environment.MachineName);

    resourceBuilder.AddAttributes(new Dictionary<string, object>
    {
        ["machine.name"] = Environment.MachineName
    });

    var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
    if (environment is not null)
        resourceBuilder.AddAttributes(new Dictionary<string, object>
        {
            ["environment.name"] = environment
        });

    return resourceBuilder;
}

Тут мы создаем по умолчанию дефолтный ResourceBuilder и вызываем метод AddService, которому передаем имя и версию сервиса из assembly. Говорим, что не хотим автоматически генерировать InstanceId и задаем его как Environment.MachineName.

У ResourceBuilder есть возможность добавлять кастомные атрибуты. И для примера мы здесь добавляем machine.name и environment.name (окружение, где запущено ASP.NET Core-приложение). После запуска приложения эта информация отражается в метриках следующим образом:


http://localhost:8889/metrics

На примере uptime видно, что добавляется лейбл env=«production» (тот, что мы добавили в конфиге OpenTelemetry Collector), instance (MachineName из ResourceBuilder) и job (имя сервиса также из Resource Builder). Прочие атрибуты, которые мы добавляли (MachineName, EnvironmentName) не пробросились в метрики. Но вся эта информация добавляется в отдельную метрику — target_info.Таким образом, сейчас нет возможности прокинуть теги во все метрики с помощью OpenTelemetry Collector. Возможно, в будущем это добавят, потому что подобная функциональность может быть полезна.

Альтернативы для .NET


Очевидно, что OpenTelemetry Collector — не единственный инструмент для метрик. Есть и другие.

App.Metrics


Начнем с App.Metrics (https://www.appmetrics.io/getting-started/prometheus-net, https://github.com/prometheusnet/prometheus-net). Это инструмент с достаточно богатым функционалом, который предоставляет единый API для метрик. Он поддерживает большое количество бэкендов (InfluxDB, Prometheus, Graphite, Datadog и др.) и имеет огромное число расширений (HealthCheck, AspNetCore, Sql и др.). Например, выше расширение для healthcheck мы писали сами, но у App.Metrics оно есть из коробки.
Еще один плюс — очень хорошая документация с примерами кода. Есть даже примеры дашбордов для Grafana.



По звездочкам на GitHub видно, что проект достаточно популярный.



prometheus-net


Следующий пример — это рекомендованная библиотека от Prometheus. На сайте Prometheus есть на нее ссылка — это официальный клиент для .NET.

Плюсы данной библиотеки — производительность, большое количество расширений для HealthCheck, AspNetCore, Sql и т. п. Есть интеграция с Pushgateway, EventCounters, DiagnosticSource, .NET Meters. У проекта хорошая документация, и, судя по звездочкам на GitHub, это тоже достаточно популярное решение.



Авторы этой библиотеки предоставляют бенчмарки, которые сравнивают prometheus-net с OpenTelemetry:



Видно, что для 10 миллионов измерений потребление CPU и памяти у OpenTelemetry значительно выше. Но при инициализации 10 тысяч тайм-серий (то есть создании 10 тысяч инструментов и метрик) обратная картина: OpenTelemetry тратит меньше CPU и памяти, чем prometheus-net.

Вряд ли в реальных приложениях будет создание 10 000 метрик, ситуация больше характерна для бенчмарков. Ну и обычно метрики создаются только при старте приложения, а измерение происходит на протяжении всей его работы. Поэтому для долгоживущих приложений, вероятно, актуальным будет первый бенчмарк.

Prometheus.Client


Следующая библиотека — форк prometheus-net с высокой производительностью и низкой аллокацией памяти (https://github.com/prom-clientnet/prom-client).

API этого инструмента достаточно урезанное, в нем мало функционала и нет никаких расширений. Эту библиотеку стоит использовать, когда нужен минимальный выход на метрики. Если потребуется дополнительная функциональность, нужно быть готовым писать ее самостоятельно.



Здесь также есть бенчмарки, которые сравнивают Prometheus.Client и prometheus-net.



Initialize 10K timeseries: prometheus-net 8.0.0 vs Prometheus.Client 5.2.0

Видно, что Prometheus.Client тратит значительно меньше и CPU, и памяти, чем prometheus-net.

Вместо итогов раздела я подготовил сводную табличку, где и какую библиотеку лучше использовать.



Вообще OpenTelemetry может быть похож на серебряную пулю. Но в ряде кейсов лучше другие библиотеки. Поэтому, перед тем как выбирать инструменты для метрик, стоит убедиться, какие библиотеки в принципе подходят под требования проекта.

Alerting


Еще одна важная часть мониторинга приложений — это алерты. Это мощный инструмент системы мониторинга Prometheus, который позволяет автоматизировать оповещения о возникающих в работе приложений проблемах.

Можно настроить достаточно гибкие правила как для самих алертов, так и для нотификаций. Нотификации можно получать через большое количество каналов — по электронной почте, в любых мессенджерах и т. п. Так что можно быстро реагировать на возникающие проблемы и минимизировать время простоя предложений.

Принцип работы алертов Prometheus довольно прост. Метрики собираются в базу данных и нам нужно определить правила, по которым будут срабатывать алерты. Это запросы на PromQL, которые, если возвращают результат, значит, метрика считается сработавшей.

groups:
- name: demo_service
  rules:
  - alert: demo_service_unhealthy.production
    expr: health{job="Dotnext.Demo.Service", env="production"} == 0
    for: 5m
    labels:
      priority: low
      severity: warning
    annotations:
      summary: "{{ $labels.app }} is unhealthy on '{{ $labels.instance }}'"

Тут можно указать имя метрики, лейблы, дополнительное описание.

Я считаю, что хорошо хранить эти алерты рядом с кодом сервисов, для которых они написаны. Но обычно сервисы разворачиваются на различных средах — стейджинг, продакшн и тому подобное — и значения у алертов могут отличаться в зависимости от среды. Поэтому удобно уметь шаблонизировать файлы для алертов. А поскольку большинство алертов в Yaml-файле, удобно использовать для этого Go template (он хорошо работает с Yaml) или придумать свой формат и написать утилиту для генерации файлов по шаблону.

Вот как может выглядеть простейший шаблон с помощью Go template (https://github.com/hairyhenderson/gomplate):

Файл demo_service_rules.yml
groups:
- name: demo_service
  rules:
  - alert: demo_service_unhealthy.${ .env }$
    expr: health{job="${ .app }$", env="${ .env }$"} == 0
    for: ${ .default_duration }$
    labels:
      priority: low
      severity: warning
    annotations:
      summary: "{{ $labels.app }} is unhealthy on '{{ $labels.instance }}'"

И остается создать файл конфига, в котором будут определены переменные.

Файл config.production.yml
app: Dotnext.Demo.Service
env: production
default duration: 5m

Утилита Go template позволяет по шаблону и файлу конфига генерировать файлы с алертами для разных сред. Это удобно внедрять на CI и на CD.

./gomplate_windows-amd64-slim.exe \
	-f demo_service_rules.yml \
	-c .=config.production.yml \
	-o demo_service_rules.production.yml \
	--left-delim '${' \
	--right-delim '}$'

Grafana


Центральный инструмент для мониторинга — это, конечно, Grafana (https://grafana.com/). Это мощная платформа с открытым исходным кодом, которая позволяет визуализировать и анализировать данные. У нее есть большое количество интеграций с различными системами, что делает ее популярным выбором для мониторинга и наблюдаемости.



Подробно останавливается на возможностях Grafana не буду. Но если кратко, она позволяет подключиться к различным источникам и централизовать данные в одном месте, создавая всесторонние визуализации для метрик, логов и трейсов.

У Grafana есть отличная интеграция с Prometheus. Есть раздел Explorer, где мы можем писать запросы на PromQL, и Grafana будет в реальном времени все отрисовывать. Также есть хорошая интеграция с алертами — можно посмотреть настроенные алерты, их статус, описание и т. п. Алерты можно группировать, включать silence mode и т. п.

Вот так может выглядеть простейший dashboard для сервиса, который мы разрабатывали в примерах:



Сюда можно добавить информацию об аптайме и текущем состоянии сервиса, можно отображать результаты здоровья сервиса, которые вычисляются на основе healthcheck-ов. Можно наблюдать за количеством обработанных сообщений, пропускной способностью и latency, строить графики в зависимости от типа сообщения. В общем, Grafana предоставляет очень богатый набор возможностей для визуализации наших телеметрических данных.

Вот как может выглядеть дашборд Grafana по процессу:



Выводы


В этой статье я рассмотрел метрики в .NET на примере OpenTelemetry и Prometheus. Напомню, что весь код, который показан в статье, доступен на GitHub здесь.

Хотел бы подчеркнуть следующее.
  • Метрики — это важный инструмент мониторинга и анализа производительности, доступности, надежности и других аспектов приложения. Вы начинаете лучше понимать, что происходит внутри сервисов, становитесь более уверенными в проектах. Лично я очень часто смотрю на дашборды и алерты, особенно после релизов.
  • Измеряйте как можно раньше. Чем раньше у вас появятся метрики, тем раньше вы сможете производить оптимизации, находить ранее неизвестные проблемы, узкие места и потенциальные точки роста.
  • OpenTelemetry — это хороший выбор. Это богатый набор библиотек для инструментации метрик в .NET-приложениях.
  • Учитывайте накладные расходы, которые вызывают метрики.
  • Выбирайте инструменты, которые вам подходят.

На этом все. То, что я описал выше, применяется в массе флагманских продуктов «Лаборатории Касперского», в том числе в Kaspersky Endpoint Security, Kaspersky Password Manager, Kaspersky Light Agent. В каждом из них используется .NET, а весь код находится в монорепозитории, благодаря чему разработчик может изучать проекты других команд, а также общаться и обмениваться опытом напрямую с их авторами.

Если вам интересен данный подход, подробности есть по этой ссылке.

Комментарии (2)


  1. Adgh
    26.07.2024 15:29

    Не упомянута возможность интеграции OpenTelemetry с Sentry, считаю совершенно не справедливо)