Мной давно не публиковались статьи и вот опять… Данная статья получилась не очень большой, но, надеюсь, полезной. Когда-то мы решили использовать для сбора метрик Prometheus, но… Спустя время, мы решили перейти на Elastic APM, т. к. весь стек для Elastic у нас уже был и мы решили поддерживать метрики в рамках этого стека.
Итак, Elastic APM — инструмент для работы с метриками, для этого в приложении используется Elastic APM Agent — агенты для сбора метрик для различных языков. Мы использовали Elastic APM .NET Agent. Пакет поддерживается .NET Framework, начиная с версии 4.6.2.
Агенты отправляют метрики на сервер (Elastic APM Server). Все необходимые настройки прописываются в web.config с определёнными ключами, которые ожидает Elastic APM Agent.
Нам необходимо настроить, как минимум, url для Elastic APM Server. Для того, чтобы разбить, при отображении метрики в ЛК, по приложениям и средам, нам нужны следующие параметры:
- ElasticApm:ServiceName — имя сервиса, должно удовлетворять следующим правилам: ^[a-zA-Z0-9 _-]+$ .
- ElasticApm:Environment — имя среды позволяет фильтровать данные на глобальном уровне в приложении. Поддерживается только в Kibana, начиная с версии 7.2.
Для настройки времени, через которое будут отправляться метрики, нам необходим следующий параметр:
- ElasticApm:MetricsInterval — позволяет задать время, через какое метрики будут отправлены на сервер, по умолчанию — 5 с. Если время будет установлено в 0, метрики не будут отправляться на сервер. Все измерения для этого параметра ведутся в секундах.
Можно также настроить уровень логгирования: ElasticApm:LogLevel.
Пример заполнения web.config:
<?xml version="1.0" encoding="utf-8"?>
<!-- ... -->
<configuration>
<!-- ... -->
<appSettings>
<!-- ... -->
<add key="ElasticApm:ServerUrls" value="https://my-apm-server:8200" />
<add key="ElasticApm:MetricsInterval" value="10" />
<add key="ElasticApm:Environment" value="Stage" />
<add key="ElasticApm:ServiceName" value="Web.Api" />
<!-- ... -->
</appSettings>
<!-- ... -->
</configuration>
Единицей работы пакета Elastic APM Agent являются транзакции — объекты типа ITransaction. Данные транзакции собираются внутри объекта Reporter и отправляются на сервер раз в заданное время. Настройки времени отправки приведены выше.
Старт транзакции начинается с вызова метода StartTransaction, завершение вызовом метода End(). Помимо методда StartTransaction также можно использовать метод CaptureTransaction, но для него не вызывается метод End(), всё необходимое передается в качестве параметров в метод. Если неободимо зафиксировать исключения, то для этого есть метод CaptureException.
Данный метод запишет объект транзакции, который будет содержать переданное ей исключение. Вся дополнительная информация — метаданные — записывается в объект транзакции при помощи заполнения свойства Labels у объекта транзакции.
Итак, что же получилось? Для начала добавляем модель, которая будет содержать необходимые для нас настройки объекта ITransaction:
public class MeasurementData
{
public static string ApmServerUrl => ConfigurationManager.AppSettings["ElasticApm:ServerUrls"] ?? "http://localhost:8200";
public static string MetricsInterval => ConfigurationManager.AppSettings["ElasticApm:MetricsInterval"] ?? "10";
public static string ApmEnvironment => ConfigurationManager.AppSettings["ElasticApm:Environment"] ?? "local";
public static string ServiceName => ConfigurationManager.AppSettings["ElasticApm:ServiceName"] ?? "Api";
public ITransaction MetricsObject { get; set; }
// Создаём объект транзакции
public static ITransaction Create(string metricsName)
{
Environment.SetEnvironmentVariable(ConfigConsts.EnvVarNames.ServerUrls, ApmServerUrl);
Environment.SetEnvironmentVariable(ConfigConsts.EnvVarNames.MetricsInterval, MetricsInterval);
Environment.SetEnvironmentVariable(ConfigConsts.EnvVarNames.Environment, ApmEnvironment);
Environment.SetEnvironmentVariable(ConfigConsts.EnvVarNames.ServiceName, ServiceName);
return Agent.Tracer.StartTransaction(metricsName, ApiConstants.TypeRequest);
}
}
Дальше, создаём builder, в котором будем создавать объект транзакции, записывать в транзакцию метаданные, создавать транзакции для сохранения исключения, завершать работу с транзакцией:
public class MetricsBuilder
{
private readonly MeasurementData _measurementData = new MeasurementData();
private string _metricName;
public void BuildMetrics(string metricName)
{
// если нам не передали название метрики, обобщаем его
_metricName = string.IsNullOrEmpty(metricName) ? "api_payment_request_duration" : metricName;
CheckAndCreateMetricsObjects();
}
// Добавляем метаданные для нашей метрики
public void AddMetricsLabels(string key, string value)
{
CheckAndCreateMetricsObjects();
if (!_measurementData.MetricsObject.Labels.ContainsKey(key))
{
_measurementData.MetricsObject.Labels.Add(key, value);
return;
}
_measurementData.MetricsObject.Labels[key] = value;
}
// Обрабатываем полученное исключение
public void CaptureMetricException(Exception exception)
{
CheckAndCreateMetricsObjects();
_measurementData.MetricsObject.CaptureException(exception);
}
public void Dispose()
{
CheckAndCreateMetricsObjects();
_measurementData.MetricsObject.End();
}
// Проверяем, была ли уже создан объект метрики,
// если нет - создаем
private void CheckAndCreateMetricsObjects()
{
if (_measurementData.MetricsObject == null)
{
_measurementData.MetricsObject = MeasurementData.Create(_metricName);
Logger.Info($"{nameof(MetricsBuilder)}: CurrentTransaction: {JsonConvert.SerializeObject(Agent.Tracer.CurrentTransaction)} " +
$"ServerUrls: {JsonConvert.SerializeObject(Agent.Config.ServerUrls)} " +
$"MetricsIntervalInMilliseconds: {JsonConvert.SerializeObject(Agent.Config.MetricsIntervalInMilliseconds)}");
}
}
}
public interface IMetricsService : IDisposable
{
void AddMetricsLabels(string key, string value);
void CaptureMetricException(string message, Exception exception);
}
public class MetricsService : IMetricsService
{
private readonly MetricsBuilder _builder;
public MetricsService(string metricName)
{
_builder = new MetricsBuilder();
Build(metricName);
}
public void AddMetricsLabels(string key, string value)
{
try
{
_builder.AddMetricsLabels(key, value);
}
catch (Exception exception)
{
CaptureMetricException("Can't write metrics labels", exception);
}
}
public void CaptureMetricException(string message, Exception exception)
{
Logger.Error(message, exception);
try
{
_builder.CaptureMetricException(exception);
}
catch (Exception exec)
{
Logger.Error("Can't write capture exception of metrics", exec);
}
}
public void Dispose()
{
try
{
_builder.Dispose();
}
catch (Exception exception)
{
CaptureMetricException($"Can't to do correct dispose of object: {typeof(MetricsService)}", exception);
}
}
private void Build(string metricName)
{
try
{
_builder.BuildMetrics(metricName);
}
catch (Exception exception)
{
CaptureMetricException("Can't create metrics object", exception);
}
}
}
Для класса, в котором должны собирать метрики, создаём декоратор, в котором будем использовать наш класс MetricsService. Не забываем про регистрацию декоратора для корректного внедрения зависимостей.
public class TestClientMetricsService : ITestObject
{
private readonly ITestObject _testObject;
public GatewayClientMetricsService(ITestObject testObject)
{
_testObject = testObject;
}
public ProcessingResult TestMethod()
{
return WriteMetrics("Test_Method", () => _testObject.Test());
}
private ProcessingResult WriteMetrics(string methodName, Func<Result> testMethod)
{
using (var metricsService = new MetricsService(methodName))
{
try
{
metricsService.AddMetricsLabels("assemblyName", Assembly.GetCallingAssembly().FullName);
var requestResult = testMethod();
metricsService.AddMetricsLabels("success", requestResult?.Success.ToString() ?? "false");
metricsService.AddMetricsLabels("resultCode", requestResult?.GetResultCode().ToString());
return requestResult;
}
catch (Exception exception)
{
metricsService.CaptureMetricException("Can't write metrics", exception);
throw;
}
}
}
}
Надеюсь, моя статья оказалась полезной для желающих использовать Elastic APM в качестве инструмента для сбора метрик.