Как отслеживать все исключения которые возникают в .NET приложении?
Встроенных инструментов 2:
1. С помощью dotnet-counters можно подключиться к работающему .NET процессу и снимать данные с него
2. Метрики приложения (OpenTelemetry / AppMetrics / и т.п.)
Чтобы начать собирать счетчик исключений с OpenTelemetry достаточно вызвать AddRuntimeInstrumentation.
Помимо кол-ва исключений там будет много и других полезных метрик (полный список документируется здесь)
До .NET 9.0 метрика (dotnet_exceptions_total) собирает просто кол-во исключений, а начиная с .NET 9.0 можно смотреть в разрезе по типу исключения, что дает гораздо больше информации о происходящем, но всё еще недостаточно.
Данная метрика собирается по событию AppDomain.Current.FirstChanceException, которая срабатывает на каждый throw любого исключения в текущем процессе.
Поэтому обработчик события должен быть очень быстрым чтобы не тормозить приложение и при этом не выбрасывать новые исключения иначе можно уйти в бесконечный цикл или даже крашнуть рантайм. Полезная ремарка есть в документации события.
Еще важно помнить, что на текущий момент async await разворачивается компилятором в FSM (покрайне мере до async2), внутри которого в случае исключений будут их rethrow на каждый await, а значит чем глубже стек вызовов, тем больше лишних триггеров FirstChanceException. Поэтому метрика с одной стороны врёт, с другой нет. Тут смотря как посмотреть.
А так как на одно исключение может быть создано много событий, то нужно сэмплировать иначе будет очень много бесполезных логов.
Полный листинг доступен на github.
Ниже разберем некоторые моменты:
[ThreadStatic]
private static Dictionary? _sampler;
private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
{
var exceptionType = e.Exception.GetType();
ref var counter = ref CollectionsMarshal.GetValueRefOrAddDefault(_sampler, exceptionType, out var exists);
var needLog = !exists || Interlocked.Increment(ref counter) % LOG_EVERY_N_EXCEPTIONS == 0;
if (needLog)
{
LogExceptionSample(e.Exception, exceptionType.FullName);
}
}
Так как необходимо сэмплировать по каждому типу исключения отдельно, то нужно хранить эти счетчики и здесь подходит словарь (_sampler).
И чтобы атомарно увеличивать счетчики воспользуемся Interlocked. Чтобы с ней работать требуется передать ссылку на значимый тип. Если бы счетчик был просто полем или локальной переменной - то всё было бы просто.
Но так как значение находится в словаре, то воспользуемся CollectionsMarshal.GetValueRefOrAddDefault. Он возьмет ссылку на значение в словаре (если его не было добавит) и дальше передаем в Interlocked.Increment
В лог запишется первое появление исключения и каждые последующие кратные N.
Чтобы приложение не ушло в бесконечный цикл, нужно чтобы метод HandleFirstChanceException вообще не выбрасывал никаких исключений (даже неконтролируемые OOM). Это не всегда возможно обеспечить, особенно если вызывать сторонние библиотеки.
Для этого воспользуемся хаком, который я подглядел в исходниках Microsoft.
Её суть в оборачивании флагом небезопасного участка кода. И пока флаг поднят, порожденные новые исключения будут проигнорированны данным обработчиком в текущем потоке (т.к. ThreadStatic)
[ThreadStatic]
private static bool _handlingFirstChanceException;
private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
{
if (_handlingFirstChanceException)
{
return;
}
_handlingFirstChanceException = true;
try
{
// unsafe code
}
catch (Exception _)
{
// no-op
}
finally
{
_handlingFirstChanceException = false;
}
}
Для любителей high performance кода может быть интересно заглянуть в метод Trim.
В итоге данный ExceptionTracker позволяет залогировать каждые N исключений каждого типа и тем самым иметь больше полезной информации для разбора причин, почему эти исключения возникают.
Встроенных инструментов 2:
1. С помощью dotnet-counters можно подключиться к работающему .NET процессу и снимать данные с него
2. Метрики приложения (OpenTelemetry / AppMetrics / и т.п.)
Чтобы начать собирать счетчик исключений с OpenTelemetry достаточно вызвать AddRuntimeInstrumentation.
Помимо кол-ва исключений там будет много и других полезных метрик (полный список документируется здесь)
До .NET 9.0 метрика (dotnet_exceptions_total) собирает просто кол-во исключений, а начиная с .NET 9.0 можно смотреть в разрезе по типу исключения, что дает гораздо больше информации о происходящем, но всё еще недостаточно.
Данная метрика собирается по событию AppDomain.Current.FirstChanceException, которая срабатывает на каждый throw любого исключения в текущем процессе.
Поэтому обработчик события должен быть очень быстрым чтобы не тормозить приложение и при этом не выбрасывать новые исключения иначе можно уйти в бесконечный цикл или даже крашнуть рантайм. Полезная ремарка есть в документации события.
Еще важно помнить, что на текущий момент async await разворачивается компилятором в FSM (покрайне мере до async2), внутри которого в случае исключений будут их rethrow на каждый await, а значит чем глубже стек вызовов, тем больше лишних триггеров FirstChanceException. Поэтому метрика с одной стороны врёт, с другой нет. Тут смотря как посмотреть.
А так как на одно исключение может быть создано много событий, то нужно сэмплировать иначе будет очень много бесполезных логов.
Полный листинг доступен на github.
Ниже разберем некоторые моменты:
[ThreadStatic]
private static Dictionary? _sampler;
private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
{
var exceptionType = e.Exception.GetType();
ref var counter = ref CollectionsMarshal.GetValueRefOrAddDefault(_sampler, exceptionType, out var exists);
var needLog = !exists || Interlocked.Increment(ref counter) % LOG_EVERY_N_EXCEPTIONS == 0;
if (needLog)
{
LogExceptionSample(e.Exception, exceptionType.FullName);
}
}
Так как необходимо сэмплировать по каждому типу исключения отдельно, то нужно хранить эти счетчики и здесь подходит словарь (_sampler).
И чтобы атомарно увеличивать счетчики воспользуемся Interlocked. Чтобы с ней работать требуется передать ссылку на значимый тип. Если бы счетчик был просто полем или локальной переменной - то всё было бы просто.
Но так как значение находится в словаре, то воспользуемся CollectionsMarshal.GetValueRefOrAddDefault. Он возьмет ссылку на значение в словаре (если его не было добавит) и дальше передаем в Interlocked.Increment
В лог запишется первое появление исключения и каждые последующие кратные N.
Чтобы приложение не ушло в бесконечный цикл, нужно чтобы метод HandleFirstChanceException вообще не выбрасывал никаких исключений (даже неконтролируемые OOM). Это не всегда возможно обеспечить, особенно если вызывать сторонние библиотеки.
Для этого воспользуемся хаком, который я подглядел в исходниках Microsoft.
Её суть в оборачивании флагом небезопасного участка кода. И пока флаг поднят, порожденные новые исключения будут проигнорированны данным обработчиком в текущем потоке (т.к. ThreadStatic)
[ThreadStatic]
private static bool _handlingFirstChanceException;
private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
{
if (_handlingFirstChanceException)
{
return;
}
_handlingFirstChanceException = true;
try
{
// unsafe code
}
catch (Exception _)
{
// no-op
}
finally
{
_handlingFirstChanceException = false;
}
}
Для любителей high performance кода может быть интересно заглянуть в метод Trim.
В итоге данный ExceptionTracker позволяет залогировать каждые N исключений каждого типа и тем самым иметь больше полезной информации для разбора причин, почему эти исключения возникают.