Вступление
Всем привет.
При разработке микросервисов важную часть уделяют Observability - способность чувствовать свои сервисы как 3 руку.
Одним из компонентов часто выделяют трейсинг запросов.
За многие года создания микросервисных архитектур накопилось большое число систем, заточенных именно под это.
Время шло и в итоге после слияния OpenCensus и OpenTracing в 2019 году родился OpenTelemetry.
В этой статье опишу подключение OpenTelemetry в ASP.NET Core проект + некоторые варианты его использования.
Что такое OpenTelemetry
Трейсинг запросов является важной частью Observability систем. Микросервисов в частности.
В 2019 году появилась OpenTelemetry - спецификация распределенной трассировки.
Позже она обросла целой экосистемой и теперь состоит из наборов API, SDK и библиотек для различных языков программирования.
Многие продукты и системы уже имеют поддержку OpenTelemetry: AWS, Azure, Google Could, Grafana, Oracle
Архитектуру можно разбить на 3 слоя:
Хост
Коллектор
Вендор
Хост
Хост - само приложение.
Состоит из 3 частей:
API - определенные в спецификации интерфейсы для сбора метрик и трейсинга
SDK - реализация этого API
Exporter - компонент занимающийся отправкой полученных данных
Здесь можно провести аналогию с архитектурой логирования в ASP.NET Core:
API - Microsoft.Extensions.Logging
SDK - Serilog, NLog, log4net
Exporter - Console, Serilog, ELK
Коллектор
Коллектор - сервис, занимающийся сбором, обработкой и отправкой замеров/трейсов вендору
Может работать как на той же машине, что и приложение (Агент), так и на другой (Коллектор)
Вендор
Вендор - сервис, хранящий собранные данные.
Например, Jaeger (поддерживает OLTP порт) или Zipkin (для него есть правила трансформации, которые выполняет коллектор).
Сравнение OpenTelemetry и System.Diagnostics
Начиная с .NET 5 были добавлены типы из пространства System.Diagnostics
, которые позволяют производить трейсинг работы приложения без необходимости подключения дополнительных библиотек.
Речь о Activity
, ActivitySource
, ActivityListener
.
Они коррелируют с понятиями определенными в OpenTelemetry.
.NET |
OpenTelemetry |
Комментарий |
---|---|---|
Activity |
Span |
Операция, производимая приложением (бизнес-логика, запросы) |
ActivitySource |
Tracer |
Создатель Activity/Span |
Tag |
Attribute |
Метаданные спана/операции |
ActivityKind |
SpanKind |
Взаимоотношения между зависимыми спанами |
ActivityContext |
SpanContext |
Контекст выполнения, который можно передать в другие спаны |
ActivityLink |
Link |
Ссылка на другой спан |
Для приведения к единому знаменателю в OpenTelemetry добавили обертки вокруг System.Diagnostics, который работает с понятиями из OpenTelemetry. Эти типы объединены под одним понятием Tracing shim
.
System.Diagnostics |
Tracing shim |
---|---|
Activity |
TelemetrySpan |
ActivitySource |
Tracer |
ActivityContext |
SpanContext |
Но все же, создатели библиотеки рекомендуют пользоваться System.Diagnostics
вместо Tracing shim
. Дальше буду использовать System.Diagnostics
.
Трейсинг в System.Diagnostics
Для трейсинга в .NET используется Activity API, предоставляемый System.Diagnostics
.
Алгоритм работы с ним следующий:
Определяем источник событий:
ActivitySource
private static readonly AssemblyName CurrentAssembly = typeof(Tracing).Assembly.GetName();
private static string Version => CurrentAssembly.Version!.ToString();
private static string AssemblyName => CurrentAssembly.Name!;
public static readonly ActivitySource ConsumerActivitySource = new(AssemblyName, Version);
В интересуемой операции создаем начинаем отслеживание новой активности
public const string KafkaMessageProcessing = "Обработка сообщения из кафки";
public Activity StartActivity()
{
var activity = ConsumerActivitySource.StartActivity(KafkaMessageProcessing);
// ...
return activity;
}
Добавляем метаданные
// https://github.com/open-telemetry/semantic-conventions/blob/main/semantic_conventions/trace/general.yaml
activity?.SetTag("thread.id", Environment.CurrentManagedThreadId);
activity?.SetTag("thread.name", Thread.CurrentThread.Name);
activity?.SetTag("enduser.id", Thread.CurrentPrincipal?.Identity?.Name);
SetLineNumber(activity);
void SetLineNumber(Activity? a, [CallerLineNumber] int lineNumber = 0)
{
a?.SetTag("code.lineno", lineNumber);
}
Заканчиваем событие
span.Stop();
// span.Dispose(); - вызывает span.Stop(), т.е. одно и то же
Как полученные Activity
обрабатываются - лежит на ActivityListener
, но это уже другая история делает подключенная библиотека.
Заметки:
Метод
StartActivity
может вернутьnull
, если никто не подписан на событие. При всех вызовах методов нужно делать проверку наnull
Activity
реализуетIDisposable
. Можно не вызыватьStop
вручную, а использоватьusing
Activity
позволяет делать записи о пользовательских событиях:activity.AddEvent(new ActivityEvent("Что-то случилось"))
. Дополнительно в библиотеке есть метод расширения для записи исключений:activity.RecordException(ex)
Раз мы можем кидать/записывать исключения, то надо уметь отслеживать место, где исключение было брошено. Для этого можно выставить статус спана:
activity.SetStatus(ActivityStatusCode.Error, ex.Message)
. Под капотом,activity.RecordException(ex)
работает через этот метод. (КромеError
естьOk
иUnset
, но не видел варианта их использования)У каждой активности есть
Baggage
- коллекция ассоциированных с операцией данных. Разница в том, чтоBaggage
может использоваться логикой приложения и передается между контекстами (сериализуется), аTag
- это просто метаданные для расследования инцидентов. Доступ ведется через одноименное свойство:activity.Baggage
-
Названия атрибутов взяты не из головы. Их наименование регламентировано спецификацией. Примеры некоторых атрибутов:
db.system
- название СУБД, с которой работает клиент (mssql
,postgresql
,clickhouse
)http.request.method
- HTTP метод, использованный при запросе (GET
,POST
)rpc.system
- тип используемого RPC запроса (grpc
,dotnet_wcf
,java_rmi
)
Вот пример полного пути выполнения:
public async Task<IActionResult> ProcessRequest()
{
using var activity = MyActivitySource.StartActivity(ActivityName);
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
activity?.SetTag("enduser.id", userId);
try
{
// Бизнес-логика
}
catch (Exception ex) when (activity is not null)
{
activity.RecordException(ex);
activity.SetStatus(ActivityStatusCode.Error);
throw;
}
}
ActivityKind - отношения между спанами
ActivityKind
представляет собой тип отношений между родительским и дочерним спанами. Его аналог в OpenTelemetry - SpanKind
public enum ActivityKind
{
/// <summary>
/// Default value.
/// Indicates that the Activity represents an internal operation within an application, as opposed to an operations with remote parents or children.
/// </summary>
Internal = 0,
/// <summary>
/// Server activity represents request incoming from external component.
/// </summary>
Server = 1,
/// <summary>
/// Client activity represents outgoing request to the external component.
/// </summary>
Client = 2,
/// <summary>
/// Producer activity represents output provided to external components.
/// </summary>
Producer = 3,
/// <summary>
/// Consumer activity represents output received from an external component.
/// </summary>
Consumer = 4,
}
Описания значений перечислений уже говорят что и когда использовать. Но для краткости к нему прилагается сравнительная таблица
ActivityKind |
Синхронное взаимодействие |
Асинхронное взаимодействие |
Входящий запрос |
Исходящий запрос |
---|---|---|---|---|
Internal |
||||
Client |
да |
да |
||
Server |
да |
да |
||
Producer |
да |
возможно |
||
Consumer |
да |
возможно |
Тип спана указывается в атрибуте span.kind
Например, когда ASP.NET Core принимает запрос, то используется ActivityKind.Server
А когда HttpClient
отправляет запрос, то ActivityKind.Client
Подключение OpenTelemetry
Теперь разберемся как подключать OpenTelemetry в проект.
Все библиотеки OpenTelemetry имеют префикс OpenTelemetry
(этот префикс зарезервирован).
Для подключения базовой функциональности в ASP.NET Core необходимо подключить:
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
Первая команда подключает функциональность OpenTelemetry, а вторая - методы расширения для регистрации сервисов.
Многие библиотеки OpenTelemetry находятся в
prerelease
статусе, поэтому в менеджере пакетов просто так не отобразятся.
Следующий этап - включить функциональность OpenTelemetry в ASP.NET Core (регистрация в провайдере сервисов).
Это можно сделать методом расширения AddOpenTelemetry()
.
Но он только добавляет SDK в провайдер сервисов. Трейсинг подключается отдельно: AddOpenTelemetry().WithTracing(...)
.
Этот метод принимает лямбду для настройки трейсинга в приложении:
Выставление метаинформации о приложении
Подключение экспортеров трейсов
Подписка на интересующие события
Выставление информации о приложении
Для представления информации о чем-либо используется класс Resource
.
По факту это просто список пар ключ-значение. Информация о приложении выставляется через него.
Точнее через ResourceBuilder
, для которого в итоге вызывается Build()
и получается результирующий Resource
.
Для настройки можно использовать 2 варианта/метода:
SetResourceBuilder(ResourceBuilder builder)
- вручную выставляем нашегоResourceBuilder
с выставленными значениямиConfigureResource(Action<ResourceBuilder> configure)
- лямбда с настройкой стандартногоResourceBuilder
.
Информацию можно задать несколькими способами:
AddService
- вручную задать название, версию, ID экземпляра сервисаAddAttributes
- выставить информацию приложения в виде перечисления пар ключ-значениеAddDetector
- получить информацию о приложении через переданный детектор (возможно получение детектора из переменных окружения)AddEnvironmentVariableDetector
- задать информацию о приложении через стандартные переменные окружения:OTEL_RESOURCE_ATTRIBUTES
,OTEL_SERVICE_NAME
Что такое детектор
При настройке информации о приложении по факту везде используется метод AddDetector
. Остальное - методы расширения.
Детектор - это реализация интерфейса IResourceDetector
/// <summary>
/// An interface for Resource detectors.
/// </summary>
public interface IResourceDetector
{
/// <summary>
/// Called to get a resource with attributes from detector.
/// </summary>
/// <returns>An instance of <see cref="Resource"/>.</returns>
Resource Detect();
}
Все добавленные детекторы в итоге возвращают Resource
при вызове Detect()
- информацию о приложении.
Например, есть метод расширения .AddEnvironmentVariableDetector()
,
который добавляет детекторы для выставления настроек сервиса из стандартных переменных окружения.
Такие детекторы можно создать самим.
В TemperatureApi
есть RandomSeedDetector
- детектор, который в информацию о сервисе выставляет сид для Random
.
public class RandomSeedDetector: IResourceDetector
{
private readonly IOptions<RandomOptions> _options;
public RandomSeedDetector(IOptions<RandomOptions> options)
{
_options = options;
}
public Resource Detect()
{
return new Resource(new KeyValuePair<string, object>[]
{
new("random.seed", _options.Value.RandomSeed)
});
}
}
Как можно увидеть, на вход он принимает IOptions
.
Его можно получить из IServiceProvider
- есть перегрузка принимающая его для создания нового детектора.
Именно она и используется в проекте.
tracing.ConfigureResource(rb =>
{
rb.AddDetector(sp =>
new RandomSeedDetector(sp.GetRequiredService<IOptions<RandomOptions>>()));
});
Пример конфигурирования TemperatureApi
:
tracing.ConfigureResource(rb =>
{
var name = typeof(TemperatureController).Assembly.GetName();
rb.AddService(
serviceName: name.Name!,
serviceVersion: name.Version!.ToString(),
autoGenerateServiceInstanceId: true);
rb.AddDetector(sp =>
new RandomSeedDetector(sp.GetRequiredService<IOptions<RandomOptions>>()));
})
Инструментаторы
Инструментатор - это функциональность/библиотека, которая позволяет делать трейсинг других библиотек без необходимости настраивать это самому.
Примером может служить HttpClient
.
Для его инструментирования есть библиотека OpenTelemetry.Instrumentation.Http
.
Она за вас проставит необходимые метаданные для проброса контекста при отправке запросов через HttpClient
.
Подключение - метод расширения
tracing.AddHttpClientInstrumentation();
Примеры других инструментаторов:
OpenTelemetry.Instrumentation.GrpcNetClient
OpenTelemetry.Instrumentation.AspNetCore
OpenTelemetry.Instrumentation.EntityFrameworkCore
OpenTelemetry.Instrumentation.Runtime
OpenTelemetry.Instrumentation.StackExchangeRedis
Источники событий
Источник событий - это созданный нами ActivitySource
.
Просто так OpenTelemetry не станет их слушать.
Для регистрации есть метод AddSource(params string[] names)
.
На вход он принимает названия ActivitySource
.
Моя практика работы следующая:
Создаю статический класс с
ActivitySource
(класс обычно называюTracing
)Когда начинается новая активность - обращаюсь к необходимому источнику и константе активности:
using var activity = Tracing.ApplicationActivity.StartActivity(SampleOperation);
Поэтому регистрация источников событий в AddSource
выглядит как перечисление всех названий ActivitySource
из всех подобных "классов-реестров":
tracing.AddSource(
FirstModule.Tracing.ApplicationActivity.Name,
SecondModule.Tracing.AnotherActivity.Name);
Экспортеры
Спаны собираются - хорошо, но нужно их куда-то отправить.
За это отвечают экспортеры.
Хоть это и OpenTelemetry библиотека, но экспортировать можно не только в OTEL формате.
Также есть поддержка (не только):
Jaeger - OpenTelemetry.Exporter.Jaeger
Zipkin - OpenTelemetry.Exporter.Zipkin
Stackdriver - OpenTelemetry.Exporter.Stackdriver
Подключение OpenTelemetry экспортера:
Добавляем пакет с экспортером
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
Регистрируем экспортер вызовом метода
tracing.AddOltpExporter();
Настраиваем экспортера
tracing.AddOltpExporter(oltp =>
{
oltp.Endpoint = new Uri("http://oltp:4317");
});
Собираем воедино:
builder.Services
.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddOtlpExporter(oltp =>
{
oltp.Endpoint = new Uri("http://oltp:4317");
})
.ConfigureResource(rb =>
{
var name = typeof(TemperatureController).Assembly.GetName();
rb.AddService(
serviceName: name.Name!,
serviceVersion: name.Version!.ToString(),
autoGenerateServiceInstanceId: true);
rb.AddDetector(sp =>
new RandomSeedDetector(sp.GetRequiredService<IOptions<RandomOptions>>()));
});
});
Рецепты
Для примера я сделал небольшой стенд из 3 сервисов с единственной операцией (запросом):
OpenTelemetry.SystemApi.Web
- принимает запрос от пользователя, делает HTTP запрос кTemperatureApi
и отправляет полученный объект в очередь кафки. Дальше называетсяSystemApi
OpenTelemetry.TemperatureApi.Web
- простой HTTP API с единственной ручкойtemperature/current
, который возвращает случайное число (температуру). Дальше называетсяTemperatureApi
OpenTelemetry.RecordSaver.Worker
- демон, который читает из кафки сообщения, отправляемыеSystemApi
, и сохраняет их в Postgres с помощью EF Core. Дальше называетсяRecordSaver
В качестве вендора использовал Jaeger.
Он поддерживает работу с OLTP на 4317 порту (нужно выставить переменную окружения COLLECTOR_OLTP_ENABLED=true
).
Синхронный запрос от одного сервиса к другому
Синхронный запрос в цепочке - от SystemApi
к TemperatureApi
. Он выполняется с помощью HttpClient
. Для отслеживания запросов в SystemApi
добавлен инструментатор HttpClient
, а в TemperatureApi
- ASP.NET Core
инструментатор.
Внутри контроллера SystemApi
вызывается ITemperatureService.GetTemeratureAsync()
, который делает HTTP запрос в Temperature.Api
.
Эта часть отображена в трейсе:
Первая часть принадлежит инструментатору HttpClient
на SystemApi
, а вторая - инструментатору AspNetCore
на TemperatureApi
.
Проброс контекста между различными сервисами
Что делать, если для какого-то варианта взаимодействия нет своего инструментатора?
Как в этом случае передавать контекст?
Для проброса контекста предназначен Propagators API
. Он предоставляет мини-фреймворк для передачи контекста. Транспортный слой выбирает сам пользователь - можно передавать где захочешь.
Передающая сторона:
Получает экземпляр
Propagator
.
На данный момент есть только
TextMapPropagator
, который использует строковое отображение, но планируется добавление байтового варианта.
Вызывает метод
Inject<T>(PropagationContext context, T carrier, Action<T,string,string> setter)
T carrier
- это тип используемого хранилища, аAction<T,string,string> setter
- функция для добавления данных в хранилище.
Делает запрос
Получающая сторона:
Получает экземпляр
Propagator
Получает хранилище, использовавшееся в запросе
Вызывает
Extract<T>(PropagationContext context, T carrier, Func<T,string,IEnumerable<string>> getter)
getter
используется уже для получения данных из хранилища.
-
Использует полученные данные при создании новой
Activity
:Установка
Baggage
Выставление родительского контекста
Добавление ссылок
Хватит теории, давайте практику.
К сожалению (или счастью), для кафки я не нашел инструментатора.
Поэтому написал свои декораторы, которые пробрасывают контекст.
Продьюсер:
public class TracingProducerDecorator<TKey, TValue>: IProducer<TKey, TValue>
{
private readonly IProducer<TKey, TValue> _producer;
public TracingProducerDecorator(IProducer<TKey, TValue> producer)
{
_producer = producer;
}
private const string ProducingActivity = "Kafka.Producer.Produce";
private Activity? StartActiveSpan(Message<TKey, TValue> message)
{
var activity = Tracing.WebActivitySource.StartActivity(ProducingActivity, ActivityKind.Producer);
if (activity is not null)
{
var propagationContext = new PropagationContext(activity.Context, Baggage.Current);
Propagators.DefaultTextMapPropagator.Inject(propagationContext, message.Headers ??= new Headers(),
(headers, key, value) => headers.Add(key, Encoding.UTF8.GetBytes(value)));
}
return activity;
}
public async Task<DeliveryResult<TKey, TValue>> ProduceAsync(string topic, Message<TKey, TValue> message, CancellationToken cancellationToken = new CancellationToken())
{
using var activity = StartActiveSpan(message);
try
{
var result = await _producer.ProduceAsync(topic, message, cancellationToken);
activity?.SetTag("kafka.topic", result.Topic);
activity?.SetTag("kafka.partition", result.Partition.Value);
activity?.SetTag("kafka.offset", result.Offset.Value);
return result;
}
catch (Exception e)
{
activity.RecordException(e);
activity.SetStatus(Status.Error);
throw;
}
}
public void Produce(string topic, Message<TKey, TValue> message, Action<DeliveryReport<TKey, TValue>> deliveryHandler = null!)
{
var span = StartActiveSpan(message);
try
{
_producer.Produce(topic, message, (r) =>
{
try
{
if (r.Error.IsError)
{
span?.SetStatus(ActivityStatusCode.Error, $"Ошибка кафки: {r.Error.Reason}");
}
else
{
span?.SetTag("kafka.topic", r.Topic);
span?.SetTag("kafka.partition", r.Partition.Value);
span?.SetTag("kafka.offset", r.Offset.Value);
}
span?.Dispose();
}
catch (ObjectDisposedException)
{ }
deliveryHandler(r);
});
}
catch (Exception e)
{
span?.RecordException(e);
span?.SetStatus(Status.Error);
span?.Dispose();
throw;
}
}
}
Консьюмер:
private static IEnumerable<string> GetValuesFromHeadersSafe(Headers headers, string key)
=> headers.Where(x => x.Key == key)
.Select(b =>
{
try
{
return Encoding.UTF8.GetString(b.GetValueBytes());
}
catch (Exception)
{
return null;
}
})
.Where(x => x is not null)!;
private Activity? StartActivity(ConsumeResult<Null, string> result)
{
var propagationContext = Propagators.DefaultTextMapPropagator.Extract(default, result.Message.Headers,
GetValuesFromHeadersSafe);
var span = Tracing.ConsumerActivitySource.StartActivity(
Tracing.KafkaMessageProcessing,
kind: ActivityKind.Consumer,
parentContext: propagationContext.ActivityContext);
Baggage.Current = propagationContext.Baggage;
return span;
}
Если мы попробуем положить в Baggage
какие-нибудь данные, то получим на обратной стороне.
Для приложений из стенда можно указать специальные переменные окружения:
TRACING_SEND_RANDOM_BAGGAGE=true
дляSystemApi
, чтобы генерировал и посылал случайные данныеTRACING_LOG_BAGGAGE=true
дляRecordSaver
, чтобы логировал получаемыйBaggage
Логи SystemApi
:
Логи RecordSaver
:
Функции
getter
иsetter
уPropagator
не должны выкидывать исключения
Добавление тегов в текущую Activity
Если в текущую Activity
необходимо добавить информацию.
Например, атрибуты или событие, то возникает вопрос как к ее получить.
Ответ прост - статическое свойство Activity.Current
. Оно вернет текущий Activity
, если он есть, иначе null
.
Для хранения
Activity
, используется статическое поле типаAsyncLocal<Activity>
. Поэтому обращение к свойству из различных асинхронных функций вернет текущийActivity
.
private static readonly AsyncLocal<Activity?> s_current = new AsyncLocal<Activity?>();
public static Activity? Current
{
get { return s_current.Value; }
set
{
if (ValidateSetCurrent(value))
{
SetCurrent(value);
}
}
}
Например, мы хотим сделать декоратор для какого-то сервиса, который будет добавлять событие при возникновении исключения
public class JsonExceptionEventRecorderServiceDecorator: ITemperatureService
{
private readonly ITemperatureService _service;
public async Task<double> GetTemperatureAsync(CancellationToken token)
{
try
{
return await _service.GetTemperatureAsync(token);
}
catch (JsonException e) when (Activity.Current is {} activity)
{
var @event = new ActivityEvent("Ошибка парсинга JSON",
tags: new ActivityTagsCollection(new KeyValuePair<string, object?>[]
{
new("json.error.path", e.Path)
}));
activity.AddEvent(@event);
throw;
}
}
}
Дробление большого запроса на несколько меньших
Батч операции могут оптимизировать работу системы, но иногда вся информация не может вместиться в единственный запрос, поэтому надо дробить на несколько меньше.
В примере я использую того же самого IProducer<TKey, TValue>
с декоратором, описанным ранее.
Добавил новую ручку System/state/batch
, которая делает буквально то же самое, но отправляет несколько запросов параллельно через Task.WhenAll()
var measurements = Enumerable.Range(0, amount)
.Select(_ => new WeatherForecast()
{
Id = Guid.NewGuid(),
Date = DateTime.Now,
Summary = Faker.Random.Words(5),
TemperatureC = temp
})
.ToArray();
await Task.WhenAll(measurements.Select(m => _producer.ProduceAsync("weather", new Message<Null, string>()
{
Value = JsonSerializer.Serialize(m)
},
token)));
Зависимости между спанами корректно обрабатываются.
Если внутри
.Select()
использовать отображение на другие асинхронные функции с собственной логикой (.Select(async m => {await ...; await ...;})
) то все будет работать так же корректно.
Сторонние сервисы
Представим, что мы работаем с кафкой.
Как известно, из одного топика могут читать несколько групп потребителей.
Пусть одна занимается непосредственной бизнес-логикой, а другие выполняют вспомогательную работу: синхронизация баз данных, аудит событий, составление отчетов, взаимодействия с другими сервисами экосистемы.
Как нам знать, что прочитанное сообщение относится к другому трейсу/спану?
Самый простой вариант - пометить полученный контекст как родительский.
Но в этом случае трейс замусорится лишними операциями.
Для таких ситуаций в OpenTelemetry определен Link
.
По факту, это просто метаинформация, сообщающая о корреляции/связи с другим спаном.
В .NET представляется типом ActivityLink
.
Эти ссылки должны быть добавлены во время создания Activity
.
Сервис RecordSaver
принимает на вход переменную окружения TRACING_USE_LINK=true
.
Если она выставлена, то во время создания Activity
будет использоваться не родительский контекст, а ссылка
ActivityLink[]? links = null;
ActivityContext parentContext = default;
if (useLink)
{
links = new ActivityLink[]
{
new(propagationContext.ActivityContext)
};
}
else
{
parentContext = propagationContext.ActivityContext;
}
var span = Tracing.ConsumerActivitySource.StartActivity(
Tracing.KafkaMessageProcessing,
kind: ActivityKind.Consumer,
parentContext: parentContext,
links: links);
Если сделать запрос из SystemApi
, то получим следующие результаты:
Создались 2 разных трейса:
SystemApi
+TemperatureApi
иRecordSaver
В родительский спан
RecordSaver
добавлена ссылка на спан продьюсера
Теперь RecordSaver
"независим" - генерируется свой собственный Trace Id
.
Отследить такие запросы, как можно догадаться, сложнее.
Обработка ошибок
В процессе работы могут возникнуть исключения. Отменить их невозможно, но можно записать факт их возникновения. То как это необходимо выполнять есть в спецификации
Записать исключения можно 2 способами:
Вручную сделать запись события об ошибке (сервис
SystemApi
)
var tags = new ActivityTagsCollection(new KeyValuePair<string, object?>[]
{
new("exception.type", exception.GetType().Name),
new("exception.message", exception.Message),
new("exception.stacktrace", exception.StackTrace)
});
activity.AddEvent(new ActivityEvent("exception", tags: tags));
Воспользоваться методом расширения из OpenTelemetry (сервис
TemperatureApi
)
activity.RecordException(e);
Первый вариант идентичен работе метода расширения.
Буквально делает то же самое.
Названия атрибутов для события исключения определены в спецификации.
В проекте TemperatureApi
добавил переменную окружения THROW_EXCEPTION=true
. Если она выставлена, то эндпоинт генерирует исключение.
В результате получается такой трейс:
P.S. в трейсе
TemperatureApi
нетexception.stacktrace
, так как объект исключения сначала создался, и только после добавления события вызванthrow
Думаю, эти варианты использования могут покрыть большую часть возникающих задач.
Кроме трейсинга, OpenTelemetry предоставляет аналогичную функциональность и для сбора метрик.
Причем, также используется System.Diagnostics
с нативными .NET'овскими типами.
Но это уже за рамками этой статьи.
Полезные ссылки:
stan1901
На практике какую нагрузку трейсинг даёт на систему? Есть, например, простенький микросервис, предоставляющий CRUD-операции к СУБД, выдерживающий 1000 RPS. Подключили его к трейсингу из статьи - на какую его производительность можно рассчитывать?
AshBlade Автор
Замеров не производил, сказать не могу
Но многие экспортеры OpenTelemetry тюнятся. Например, OTLP поддерживает gRPC, а другие передают через HTTP. Также все поддерживают отправку батчами, а не по одному.
Поэтому надо тюнить и тестить