Start Debugging

Исправление: CA1873 "Evaluation of this argument may be expensive and unnecessary if logging is disabled"

CA1873 срабатывает на неявный массив params object[], поэтому его вызывает почти любой вызов LogDebug. Исправьте через [LoggerMessage] или проверку IsEnabled.

CA1873 представляет собой анализатор производительности, который в SDK .NET 10 включён как предложение, а не как предупреждение, поэтому он виден в Visual Studio, Rider и dotnet format, но оставляет dotnet build чистым. Срабатывает он на неявный массив params object?[], который выделяет любой вызов в стиле ILogger.LogDebug. Это означает, что правило срабатывает практически на каждом вызове структурированного логирования хотя бы с одним аргументом, даже с обычной строкой. Настоящее исправление состоит в генерации исходного кода через [LoggerMessage], а быстрое решение сводится к проверке IsEnabled, уровень которой точно совпадает с уровнем вызова.

Текст диагностики, который вы ищете:

warning CA1873: Evaluation of this argument may be expensive and unnecessary if logging is disabled

Всё изложенное ниже проверено на SDK 10.0.201, Microsoft.Extensions.Logging 10.0.0 и C# 14, а исходный код анализатора прочитан из dotnet/sdk.

Что делает CA1873 невидимым в dotnet build?

Потому что уровень серьёзности по умолчанию в .NET 10 равен предложению (info), а диагностики уровня info не выводятся командой dotnet build и не затрагиваются параметром TreatWarningsAsErrors.

Проект с десятком вызовов LogDebug собирается полностью чисто:

    0 Warning(s)
    0 Error(s)

Превратить его в настоящее предупреждение можно одним из двух способов:

<!-- .NET 10 SDK 10.0.201: promotes every "All"-mode analyzer, CA1873 included -->
<PropertyGroup>
  <AnalysisMode>All</AnalysisMode>
</PropertyGroup>
# .editorconfig, targeted at just this rule
[*.{cs,vb}]
dotnet_diagnostic.CA1873.severity = warning

Тот же проект после этого сообщает о 12 предупреждениях CA1873. Если вы подключаете уровни серьёзности анализаторов к CI, компромиссы разобраны в статье как удержать TreatWarningsAsErrors вне сборок разработчика.

Как очевидно дешёвый аргумент всё же вызывает CA1873?

Именно эта деталь отправляет людей в поисковики. Правило смотрит не только на ваш аргумент. Оно смотрит на неявный массив params object?[], который компилятор создаёт для передачи этого аргумента, и создание непустого массива само по себе считается дорогим.

У LoggerExtensions.LogDebug нет перегрузки без params, принимающей аргументы сообщения:

// Microsoft.Extensions.Logging.Abstractions 10.0.0
public static void LogDebug(this ILogger logger, string? message, params object?[] args);

Поэтому _logger.LogDebug("v {V}", x) компилируется в выделение object[1] независимо от того, чем является x. Проверка стоимости в анализаторе считает нарушением любое создание массива, если только массив не пуст:

// dotnet/sdk, AvoidPotentiallyExpensiveCallWhenLogging.cs
static bool IsEmptyImplicitParamsArrayCreation(IArrayCreationOperation arrayCreationOperation) =>
    arrayCreationOperation.IsImplicit &&
    arrayCreationOperation.DimensionSizes.Length == 1 &&
    arrayCreationOperation.DimensionSizes[0].ConstantValue.HasValue &&
    arrayCreationOperation.DimensionSizes[0].ConstantValue.Value is int size &&
    size == 0;

Я собрал матрицу, чтобы подтвердить, что именно вызывает срабатывание. Каждый из этих случаев дал CA1873 на SDK 10.0.201:

// .NET 10, C# 14, Microsoft.Extensions.Logging.Abstractions 10.0.0
public void StringProp(Order o) => _logger.LogDebug("v {V}", o.Name);      // CA1873
public void IntProp(Order o)    => _logger.LogDebug("v {V}", o.Id);        // CA1873
public void StringField()       => _logger.LogDebug("v {V}", _nameField);  // CA1873
public void StringLocal()       { var s = "a"; _logger.LogDebug("v {V}", s); }  // CA1873
public void StringParam(string s) => _logger.LogDebug("v {V}", s);         // CA1873
public void ConstInt()          => _logger.LogDebug("v {V}", 42);          // CA1873

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

public void LiteralOnly() => _logger.LogDebug("nothing to see");           // clean

В этом и состоит вся неожиданность. С o.Name ничего не так. Изменение от ноября 2025 года под названием “Reduce noise from CA1873” специально исключило из проверки стоимости обращения к свойствам, GetType, GetHashCode и Stopwatch.GetTimestamp, но это исключение применяется к элементам массива, тогда как само выделение массива по-прежнему помечается. Для перегрузок на основе params снижение шума остаётся незаметным.

Как выглядит минимальное воспроизведение?

// .NET 10 (SDK 10.0.201), C# 14
// dotnet new console + Microsoft.Extensions.Logging.Abstractions 10.0.0
using Microsoft.Extensions.Logging;

public class OrderService(ILogger<OrderService> logger)
{
    public void Process(Order order)
    {
        // CA1873: Evaluation of this argument may be expensive
        // and unnecessary if logging is disabled
        logger.LogDebug("Order {OrderId} for {Customer}", order.Id, order.Customer);
    }
}

С <AnalysisMode>All</AnalysisMode> или явным уровнем серьёзности в .editorconfig этот единственный вызов сообщает о CA1873.

Как правильно исправить CA1873?

Используйте генератор исходного кода [LoggerMessage]. Он порождает строго типизированный метод без массива params и без упаковки, поэтому анализатору нечего помечать, а среде выполнения нечего выделять, когда уровень отключён.

// .NET 10, C# 14. The class must be partial.
public partial class OrderService(ILogger<OrderService> logger)
{
    public void Process(Order order) => LogOrder(order.Id, order.Customer);

    [LoggerMessage(Level = LogLevel.Debug, Message = "Order {OrderId} for {Customer}")]
    private partial void LogOrder(int orderId, string customer);
}

Сгенерированный метод проверяет IsEnabled до того, как обращается к своим аргументам, поэтому анализатор молчит, а вызов не стоит ничего, когда Debug выключен. Тот же механизм лежит в основе замены new Regex(…) на генератор исходного кода GeneratedRegex; если такой подход вам незнаком, начните с материала о том, что такое генератор исходного кода и когда он нужен.

Когда достаточно проверки IsEnabled?

Когда нужна правка в одну строку и нет желания перестраивать класс в partial-тип. Анализатор распознаёт такую проверку и подавляет диагностику:

// .NET 10, C# 14
if (logger.IsEnabled(LogLevel.Debug))
{
    logger.LogDebug("Order {OrderId} for {Customer}", order.Id, order.Customer);
}

Есть два ограничения, и я проверил, что нарушение каждого из них даёт диагностику:

Уровень должен совпадать точно. Защита LogDebug проверкой IsEnabled(LogLevel.Information) по-прежнему сообщает о CA1873, поскольку анализатор сравнивает константу из проверки с уровнем вызова:

if (logger.IsEnabled(LogLevel.Information))
{
    logger.LogDebug("v {V}", order.Describe());   // CA1873, levels differ
}

Проверка должна быть встроенной. Вынос её в свойство или вспомогательный метод полностью обходит анализ, поскольку анализатор обходит охватывающие операции в поисках буквального вызова ILogger.IsEnabled:

private bool DebugOn => logger.IsEnabled(LogLevel.Debug);

public void Process(Order order)
{
    if (DebugOn) { logger.LogDebug("v {V}", order.Describe()); }   // CA1873
}

Сколько на самом деле стоит незащищённый вызов?

Достаточно, чтобы иметь значение на горячем пути, и совершенно ничего за его пределами. Измерено с помощью BenchmarkDotNet 0.15.4 на .NET 10.0.5, Intel Core Ultra 7 265KF, с минимальным уровнем Information, так что вызов Debug отключён:

МетодСреднееRatioВыделено
Unguarded13.22 ns1.0064 B
Guarded0.27 ns0.020 B
SourceGenerated0.51 ns0.040 B

Эти 64 байта складываются из массива object[2] и упакованного int. Оба исправления снижают их до нуля. Обратите внимание на отношение, а не только на наносекунды: 13 нс на вызов не имеют значения в обработчике запроса, выполняющем запрос к базе данных, и очень важны в цикле, который выполняется миллион раз. Именно поэтому правило поставляется как предложение, а не как предупреждение.

Какие уровни логирования проверяет CA1873?

По умолчанию Information и ниже. Обоснование, взятое из истории коммитов самого анализатора, состоит в том, что горячие пути пишут в Debug и Trace, тогда как Warning и Error встречаются достаточно редко, чтобы накладные расходы на вызов не имели значения.

Есть также недокументированный переключатель в .editorconfig для изменения порога:

# Not listed on the CA1873 docs page. Values: trace, debug, information, warning, error, critical
[*.{cs,vb}]
dotnet_code_quality.CA1873.max_log_level = warning

Перебор всех значений на SDK 10.0.201 даёт следующую картину и обнажает ошибку:

max_log_levelУровни, дающие CA1873
traceTrace, Critical
debugTrace, Debug, Critical
information (по умолчанию)Trace, Debug, Information, Critical
warningTrace, Debug, Information, Warning, Critical
errorвсе шесть

LogCritical срабатывает при любом пороге, включая trace. Это ошибка на единицу: поставленное сравнение исключает Critical из диапазона, на котором происходит досрочный выход.

// dotnet/sdk commit 574cda32, "CA1873: Fix log level comparison"
-                    logLevel < LogLevelCritical &&
+                    logLevel <= LogLevelCritical &&

Исправление попало в dotnet/sdk 2026-06-19, уже после выхода SDK 10.0.201. Пока вы не перейдёте на SDK, который его содержит, вызовы LogCritical продолжат сообщать о CA1873 независимо от настройки max_log_level. Подавляйте их точечно, а не отключайте правило целиком.

Известное ложное срабатывание: защищённые сгенерированные вызовы

Если обернуть сгенерированный метод логирования в проверку IsEnabled, анализатор всё равно сообщит о CA1873. Это зафиксировано как открытая проблема анализатора и воспроизводится на SDK 10.0.201:

// .NET 10, C# 14. Guarded, source-generated, still reports CA1873.
if (logger.IsEnabled(LogLevel.Information))
{
    LogKeys([.. dictionary.Select(p => p.Key)]);
}

[LoggerMessage(Level = LogLevel.Information, Message = "keys {Keys}")]
private partial void LogKeys(string[] keys);

Проверка учитывается только тогда, когда она охватывает распознанный вызов ILogger. Для анализатора сгенерированный метод остаётся обычным методом, поэтому аргумент с выражением коллекции оценивается сам по себе и помечается. Подавляйте этот случай локально, пока исправление не вышло:

#pragma warning disable CA1873
    LogKeys([.. dictionary.Select(p => p.Key)]);
#pragma warning restore CA1873

Похожие правила, из-за которых сюда попадают по ошибке

CA1848 (“For improved performance, use the LoggerMessage delegates”) срабатывает в тех же местах вызова и имеет то же исправление, но касается стоимости разбора шаблона сообщения при каждом вызове, а не вычисления аргументов. Обычно вы увидите оба сразу, и [LoggerMessage] снимает оба.

CA2254 (“The logging message template should not vary between calls”) касается интерполяции строк, разрушающей ваши структурированные поля. Если вы гонитесь именно за этим, посмотрите переход от интерполяции строк в ILogger к шаблонам сообщений, где разобраны также SkipEnabledCheck и [LogProperties].

Стоит ли просто отключить правило?

Для кодовой базы, которая пишет логи на уровне Information на путях обработки запросов и не имеет измеренных горячих циклов, да. Поставьте none и вернитесь к вопросу, когда профилирование покажет, что накладные расходы логирования важны:

[*.{cs,vb}]
dotnet_diagnostic.CA1873.severity = none

Более полезная середина состоит в том, чтобы оставить уровень серьёзности по умолчанию и применять [LoggerMessage] по мере необходимости. Вы получаете подсказку IDE в тех местах вызова, которые и так правите, отсутствие шума в CI, а логирование без выделений накапливается со временем вместо того, чтобы прийти рефакторингом на 400 файлов. Выигрыш по выделениям реален, просто он не срочный, а массив params за ним тот же самый, который C# 13 начал устранять для других API.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад