| | | 1 | | using System.Globalization; |
| | | 2 | | using Microsoft.AspNetCore.Http; |
| | | 3 | | using Serilog.Events; |
| | | 4 | | using Services.ApiServices.ArmtekApi; |
| | | 5 | | |
| | | 6 | | namespace OzonOrdersWeb.Observability; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Решает, что не должно попадать в файл <c>errors-*.log</c>. |
| | | 10 | | /// |
| | | 11 | | /// Этот файл — журнал отказов самого Studio2: наблюдатель |
| | | 12 | | /// <c>Scripts/log-watcher/studio2_log_watcher.py</c> заводит по нему GitHub issue на каждую |
| | | 13 | | /// уникальную запись уровня Warning и выше. Отсеиваются два класса записей: |
| | | 14 | | /// |
| | | 15 | | /// <list type="number"> |
| | | 16 | | /// <item>транзиентный сбой чужой инфраструктуры, который приложение уже обрабатывает собственным |
| | | 17 | | /// ретраем: отказом Studio2 он не является, исправлять в коде нечего;</item> |
| | | 18 | | /// <item>телеметрия производительности об успешно завершённой операции («медленно, но успешно»): |
| | | 19 | | /// это метрика, а не отказ, и её место в Seq, а не в очереди инцидентов.</item> |
| | | 20 | | /// </list> |
| | | 21 | | /// |
| | | 22 | | /// Отсев действует только на файловый sink — в Seq и консоль такие записи уходят как были, |
| | | 23 | | /// с прежним уровнем и стеком, так что всплеск (например, длительная деградация БД) остаётся виден. |
| | | 24 | | /// Правила намеренно узкие: сверяется и источник записи, и текст конкретного состояния. |
| | | 25 | | /// </summary> |
| | | 26 | | public static class InfrastructureNoiseLogFilter |
| | | 27 | | { |
| | | 28 | | private const string RequestObservabilitySource = "OzonOrdersWeb.Observability.RequestObservabilityMiddleware"; |
| | | 29 | | private const string ClientDiagnosticsPath = "/api/diagnostics/client-event"; |
| | | 30 | | |
| | | 31 | | /// <summary> |
| | | 32 | | /// Служебные процессы Hangfire, работающие со своими же таблицами. Прикладные джобы сюда |
| | | 33 | | /// не входят: их падения логируют <c>Hangfire.Server.Worker</c> и |
| | | 34 | | /// <c>Hangfire.AutomaticRetryAttribute</c>, и они обязаны оставаться в errors-*.log. |
| | | 35 | | /// </summary> |
| | 1 | 36 | | private static readonly string[] HangfireStorageSources = |
| | 1 | 37 | | [ |
| | 1 | 38 | | "Hangfire.MySql.", |
| | 1 | 39 | | "Hangfire.Processing.BackgroundExecution", |
| | 1 | 40 | | "Hangfire.Server.RecurringJobScheduler", |
| | 1 | 41 | | "Hangfire.Server.DelayedJobScheduler", |
| | 1 | 42 | | "Hangfire.Server.ServerWatchdog", |
| | 1 | 43 | | "Hangfire.Server.ServerHeartbeatProcess" |
| | 1 | 44 | | ]; |
| | | 45 | | |
| | | 46 | | private const string HangfireWorkerSource = "Hangfire.Server.Worker"; |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// Состояния конкуренции MySQL/MariaDB, которые Hangfire переживает собственным ретраем |
| | | 50 | | /// («execution will be retried in no more than 00:00:04») и повторяет операцию сам. |
| | | 51 | | /// </summary> |
| | 1 | 52 | | private static readonly string[] TransientStorageContention = |
| | 1 | 53 | | [ |
| | 1 | 54 | | "Lock wait timeout exceeded", |
| | 1 | 55 | | "Deadlock found when trying to get lock", |
| | 1 | 56 | | "The Command Timeout expired", |
| | 1 | 57 | | "Query execution was interrupted", |
| | 1 | 58 | | "cannot acquire lock" |
| | 1 | 59 | | ]; |
| | | 60 | | |
| | | 61 | | /// <summary> |
| | | 62 | | /// Текст события <c>RelationalEventId.ConnectionError</c> EF Core. На всех контекстах включён |
| | | 63 | | /// <c>EnableRetryOnFailure</c>, поэтому одиночный обрыв соединения гасится ретраем; если же |
| | | 64 | | /// ретраи не помогли, отказ прилетает вызывающему коду и логируется там с бизнес-контекстом. |
| | | 65 | | /// </summary> |
| | | 66 | | private const string EfConnectionErrorTemplate = "An error occurred using the connection to database"; |
| | | 67 | | |
| | | 68 | | // EF Core writes these diagnostics before MySqlRetryingExecutionStrategy retries an operation. |
| | | 69 | | // The final unhandled failure is logged by the caller with application context. |
| | | 70 | | private const string EfQueryIterationFailedTemplate = "An exception occurred while iterating over the results of a q |
| | | 71 | | private const string EfTransactionErrorTemplate = "An error occurred using a transaction."; |
| | | 72 | | |
| | 1 | 73 | | private static readonly string[] RetriableMySqlTimeoutMessages = |
| | 1 | 74 | | [ |
| | 1 | 75 | | "The Command Timeout expired", |
| | 1 | 76 | | "Query execution was interrupted", |
| | 1 | 77 | | "Operation canceled" |
| | 1 | 78 | | ]; |
| | | 79 | | |
| | | 80 | | /// <summary> |
| | | 81 | | /// Предупреждения инфраструктуры ASP.NET Core о переопределении портов и адресов при запуске хоста |
| | | 82 | | /// и редиректе HTTPS при SSL-терминации на обратном прокси (Nginx). |
| | | 83 | | /// </summary> |
| | 1 | 84 | | private static readonly string[] HostingNoiseSources = |
| | 1 | 85 | | [ |
| | 1 | 86 | | "Microsoft.AspNetCore.Hosting.Diagnostics", |
| | 1 | 87 | | "Microsoft.AspNetCore.Server.Kestrel", |
| | 1 | 88 | | "Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware" |
| | 1 | 89 | | ]; |
| | | 90 | | |
| | 1 | 91 | | private static readonly string[] HostingNoiseMessages = |
| | 1 | 92 | | [ |
| | 1 | 93 | | "Overriding HTTP_PORTS", |
| | 1 | 94 | | "Overriding address(es)", |
| | 1 | 95 | | "Failed to determine the https port for redirect" |
| | 1 | 96 | | ]; |
| | | 97 | | |
| | | 98 | | /// <summary> |
| | | 99 | | /// Источники и сообщения HealthCheck с результатом Degraded. |
| | | 100 | | /// Предупреждения о временной недоступности необязательных внешних БД (OzonApi, Bitrix) или |
| | | 101 | | /// пороге диска <5 ГБ не являются отказом Studio2: основная БД доступна, приложение работает штатно. |
| | | 102 | | /// Место таких предупреждений — Seq и дашборд готовности, а не журнал отказов. |
| | | 103 | | /// </summary> |
| | 1 | 104 | | private static readonly string[] HealthCheckSources = |
| | 1 | 105 | | [ |
| | 1 | 106 | | "Microsoft.Extensions.Diagnostics.HealthChecks" |
| | 1 | 107 | | ]; |
| | | 108 | | |
| | | 109 | | private const string HealthCheckDegradedMessage = "status \"Degraded\""; |
| | | 110 | | |
| | | 111 | | /// <summary> |
| | | 112 | | /// Источники и сообщения проверки доступности внешней базы данных Bitrix. |
| | | 113 | | /// Внешняя БД Bitrix является необязательной интеграцией: при её недоступности сервис |
| | | 114 | | /// BitrixConnectionService возвращает false, кэширует статус и приложение продолжает |
| | | 115 | | /// работу без падений. Такие предупреждения отслеживаются в Seq, но исключаются из errors-*.log (#2290). |
| | | 116 | | /// </summary> |
| | 1 | 117 | | private static readonly string[] BitrixConnectionNoiseSources = |
| | 1 | 118 | | [ |
| | 1 | 119 | | "OzonRepositories.Services.BitrixConnectionService" |
| | 1 | 120 | | ]; |
| | | 121 | | |
| | 1 | 122 | | private static readonly string[] BitrixConnectionNoiseMessages = |
| | 1 | 123 | | [ |
| | 1 | 124 | | "Bitrix connection check failed", |
| | 1 | 125 | | "Access denied for user", |
| | 1 | 126 | | "to database 'cd48454_4'" |
| | 1 | 127 | | ]; |
| | | 128 | | |
| | | 129 | | /// <summary> |
| | | 130 | | /// Источники логирования исходящих HTTP-запросов. |
| | | 131 | | /// </summary> |
| | 1 | 132 | | private static readonly string[] OutboundHttpSources = |
| | 1 | 133 | | [ |
| | 1 | 134 | | "OzonOrdersWeb.Observability.OutboundRequestLoggingHandler", |
| | 1 | 135 | | "TecDocApi.API.Observability.TecDocOutboundRequestLoggingHandler" |
| | 1 | 136 | | ]; |
| | | 137 | | |
| | | 138 | | /// <summary> |
| | | 139 | | /// Сообщения о сбоях сетевого уровня и TLS-рукопожатия при обращениях к внешним сервисам. |
| | | 140 | | /// Сброс TCP-соединения сервером или сбой TLS — транзиентный отказ чужой инфраструктуры (#1994). |
| | | 141 | | /// </summary> |
| | 1 | 142 | | private static readonly string[] OutboundNetworkNoiseMessages = |
| | 1 | 143 | | [ |
| | 1 | 144 | | "The SSL connection could not be established", |
| | 1 | 145 | | "Connection reset by peer", |
| | 1 | 146 | | "The remote computer refused the network connection" |
| | 1 | 147 | | ]; |
| | | 148 | | |
| | | 149 | | private static bool IsTransientOutboundNetworkNoise(LogEvent logEvent) |
| | 149 | 150 | | => MatchesSource(logEvent, OutboundHttpSources) && ContainsAny(logEvent, OutboundNetworkNoiseMessages); |
| | | 151 | | |
| | | 152 | | /// Транзиентные сбои связи со сторонним почтовым сервером IMAP (MailKit). |
| | | 153 | | /// Временные разрывы сокета, протокольные сбои или таймауты SSL-рукопожатия обрабатываются |
| | | 154 | | /// ретраями и не являются отказом Studio2. |
| | | 155 | | /// </summary> |
| | 1 | 156 | | private static readonly string[] ImapNoiseSources = |
| | 1 | 157 | | [ |
| | 1 | 158 | | "Services.EmailRoutingServices", |
| | 1 | 159 | | "Services.HangfireService.EmailAttachmentRoutingJob", |
| | 1 | 160 | | "MailKit" |
| | 1 | 161 | | ]; |
| | | 162 | | |
| | 1 | 163 | | private static readonly string[] ImapNoiseMessages = |
| | 1 | 164 | | [ |
| | 1 | 165 | | "problems with connection", |
| | 1 | 166 | | "The server unexpected disconnected", |
| | 1 | 167 | | "An error occurred while attempting to establish an SSL or TLS connection", |
| | 1 | 168 | | "Network is unreachable", |
| | 1 | 169 | | "Connection reset by peer", |
| | 1 | 170 | | "ImapProtocolException", |
| | 1 | 171 | | "SslHandshakeException" |
| | 1 | 172 | | ]; |
| | | 173 | | |
| | | 174 | | private static bool IsTransientImapNoise(LogEvent logEvent) |
| | 145 | 175 | | => MatchesSource(logEvent, ImapNoiseSources) && ContainsAny(logEvent, ImapNoiseMessages); |
| | | 176 | | |
| | | 177 | | /// <summary> |
| | | 178 | | /// Telegram-клиент возвращает вызывающему коду <c>Failure</c> при кратковременной сетевой |
| | | 179 | | /// недоступности, сохраняя Warning в Seq и консоли. Одиночный отказ внешнего Telegram не |
| | | 180 | | /// означает сбой Studio2, поэтому не должен создавать отдельный GitHub issue из errors-лога. |
| | | 181 | | /// Правило ограничено точным источником, шаблоном клиента и сетевой причиной (#2449). |
| | | 182 | | /// </summary> |
| | | 183 | | private static bool IsTransientTelegramNetworkNoise(LogEvent logEvent) |
| | 142 | 184 | | => logEvent.Level == LogEventLevel.Warning |
| | 142 | 185 | | && MatchesSource(logEvent, ["Services.ApiServices.TelegramBotApi.TelegramBotApiClient"]) |
| | 142 | 186 | | && logEvent.MessageTemplate.Text.StartsWith("Telegram ", StringComparison.Ordinal) |
| | 142 | 187 | | && logEvent.MessageTemplate.Text.Contains("сбой запроса", StringComparison.Ordinal) |
| | 142 | 188 | | && ContainsAny(logEvent, ["Network is unreachable"]); |
| | | 189 | | |
| | | 190 | | /// <summary> |
| | | 191 | | /// Источники логирования подсистемы поиска через Elasticsearch. |
| | | 192 | | /// </summary> |
| | 1 | 193 | | private static readonly string[] ElasticsearchNoiseSources = |
| | 1 | 194 | | [ |
| | 1 | 195 | | "Services.SearchServices.OrderSearch", |
| | 1 | 196 | | "OzonOrdersWeb.HostedServices.OrderSearchStartupHostedService", |
| | 1 | 197 | | "Services.FiltersServices.DataFilterManagers.OrderDataFilterManager", |
| | 1 | 198 | | "Elasticsearch.Net", |
| | 1 | 199 | | "Nest" |
| | 1 | 200 | | ]; |
| | | 201 | | |
| | | 202 | | /// <summary> |
| | | 203 | | /// Сообщения о транзиентной недоступности узла Elasticsearch, сбоях подключения и восстановлении шардов. |
| | | 204 | | /// В Studio2 Elasticsearch — опциональный индекс ускорения: при его недоступности срабатывает штатный |
| | | 205 | | /// fallback на поиск по MariaDB. Транзиентные сбои во время старта или рестарта ноды уходят в Seq и консоль, |
| | | 206 | | /// но не засоряют errors-*.log ложными инцидентами. |
| | | 207 | | /// </summary> |
| | 1 | 208 | | private static readonly string[] ElasticsearchNoiseMessages = |
| | 1 | 209 | | [ |
| | 1 | 210 | | "all shards failed", |
| | 1 | 211 | | "The client is unable to verify that the server is Elasticsearch", |
| | 1 | 212 | | "Connection refused", |
| | 1 | 213 | | "Elasticsearch-поиск заказов недоступен — fallback на LIKE-фильтрацию", |
| | 1 | 214 | | "не удалось создать индекс — проверьте доступность Elasticsearch", |
| | 1 | 215 | | "OrderSearch startup bootstrap failed", |
| | 1 | 216 | | "node_not_connected_exception" |
| | 1 | 217 | | ]; |
| | | 218 | | |
| | | 219 | | public static bool IsTransientElasticsearchNoise(LogEvent logEvent) |
| | 123 | 220 | | => MatchesSource(logEvent, ElasticsearchNoiseSources) && ContainsAny(logEvent, ElasticsearchNoiseMessages); |
| | | 221 | | |
| | | 222 | | /// <summary> |
| | | 223 | | /// Транзиентные сбои связи со сторонним API Google Sheets (sheets.googleapis.com, oauth2.googleapis.com). |
| | | 224 | | /// Временная сетевая недоступность или ошибки авторизации Google не являются отказом Studio2 |
| | | 225 | | /// и обрабатываются локальными повторами (#2304, #2305, #2306). |
| | | 226 | | /// </summary> |
| | 1 | 227 | | private static readonly string[] GoogleSheetsNoiseSources = |
| | 1 | 228 | | [ |
| | 1 | 229 | | "Services.StockSyncServices.WarehouseStockSyncService", |
| | 1 | 230 | | "Services.StockSyncServices.WarehouseStockSyncJob", |
| | 1 | 231 | | "Google.Apis" |
| | 1 | 232 | | ]; |
| | | 233 | | |
| | 1 | 234 | | private static readonly string[] GoogleSheetsNoiseMessages = |
| | 1 | 235 | | [ |
| | 1 | 236 | | "sheets.googleapis.com", |
| | 1 | 237 | | "oauth2.googleapis.com", |
| | 1 | 238 | | "Network is unreachable", |
| | 1 | 239 | | "GoogleApiException", |
| | 1 | 240 | | "TokenResponseException" |
| | 1 | 241 | | ]; |
| | | 242 | | |
| | | 243 | | private static bool IsTransientGoogleSheetsNoise(LogEvent logEvent) |
| | 140 | 244 | | => MatchesSource(logEvent, GoogleSheetsNoiseSources) && ContainsAny(logEvent, GoogleSheetsNoiseMessages); |
| | | 245 | | |
| | | 246 | | /// <summary> |
| | | 247 | | /// CookieTempDataProvider штатно отбрасывает повреждённую клиентскую TempData-cookie и |
| | | 248 | | /// продолжает запрос с пустым TempData. Такие cookie появляются после ручного изменения, |
| | | 249 | | /// устаревшей версии приложения или обрыва записи и не означают отказа страницы входа. |
| | | 250 | | /// Отсеиваем только известный формат Base64 от встроенного провайдера (#2319). |
| | | 251 | | /// </summary> |
| | | 252 | | private static bool IsCorruptTempDataCookieNoise(LogEvent logEvent) |
| | 137 | 253 | | => logEvent.Level == LogEventLevel.Warning |
| | 137 | 254 | | && MatchesSource(logEvent, ["Microsoft.AspNetCore.Mvc.ViewFeatures.CookieTempDataProvider"]) |
| | 137 | 255 | | && logEvent.MessageTemplate.Text.StartsWith("The temp data cookie", StringComparison.Ordinal) |
| | 137 | 256 | | && ContainsAny(logEvent, ["The input is not a valid Base-64 string"]); |
| | | 257 | | |
| | | 258 | | /// <summary> |
| | | 259 | | /// Штатная отмена HTTP-запроса браузером (OperationCanceledException) при закрытии вкладки или переходе |
| | | 260 | | /// во время загрузки TransactionsGrid / остатков C0 (#2324). |
| | | 261 | | /// </summary> |
| | | 262 | | private static bool IsTransactionGridCancellationNoise(LogEvent logEvent) |
| | 136 | 263 | | => MatchesSource(logEvent, ["OzonOrdersWeb.WebServices.Transactions"]) |
| | 136 | 264 | | && (logEvent.Exception is OperationCanceledException |
| | 136 | 265 | | || ContainsAny(logEvent, ["OperationCanceledException", "The operation was canceled"])); |
| | | 266 | | |
| | | 267 | | /// <summary> |
| | | 268 | | /// Единственная точка решения для файлового sink <c>errors-*.log</c>: запись либо транзиентный |
| | | 269 | | /// шум чужой инфраструктуры, либо метрика производительности успешной операции. |
| | | 270 | | /// </summary> |
| | | 271 | | public static bool ShouldExcludeFromErrorLog(LogEvent logEvent) |
| | 77 | 272 | | => IsTransientInfrastructureNoise(logEvent) |
| | 77 | 273 | | || IsSuccessfulPerformanceTelemetry(logEvent); |
| | | 274 | | |
| | | 275 | | /// <summary>Запись — транзиентный шум инфраструктуры и в errors-*.log не нужна.</summary> |
| | | 276 | | public static bool IsTransientInfrastructureNoise(LogEvent logEvent) |
| | 177 | 277 | | => IsHangfireStorageContention(logEvent) |
| | 177 | 278 | | || IsHangfireDistributedLockWait(logEvent) |
| | 177 | 279 | | || IsHangfireDistributedLockContention(logEvent) |
| | 177 | 280 | | || IsHangfireWorkerStopRequested(logEvent) |
| | 177 | 281 | | || IsRetriedEfConnectionError(logEvent) |
| | 177 | 282 | | || IsRetriedEfQueryIteration(logEvent) |
| | 177 | 283 | | || IsUncorrelatedEfTransactionError(logEvent) |
| | 177 | 284 | | || IsHostingNoise(logEvent) |
| | 177 | 285 | | || IsDataProtectionNoEncryptorNoise(logEvent) |
| | 177 | 286 | | || IsDegradedHealthCheckNoise(logEvent) |
| | 177 | 287 | | || IsBitrixConnectionNoise(logEvent) |
| | 177 | 288 | | || IsTransientOutboundNetworkNoise(logEvent) |
| | 177 | 289 | | || IsTransientImapNoise(logEvent) |
| | 177 | 290 | | || IsTransientTelegramNetworkNoise(logEvent) |
| | 177 | 291 | | || IsTransientGoogleSheetsNoise(logEvent) |
| | 177 | 292 | | || IsCorruptTempDataCookieNoise(logEvent) |
| | 177 | 293 | | || IsTransactionGridCancellationNoise(logEvent) |
| | 177 | 294 | | || IsClientDiagnosticsRateLimitNoise(logEvent) |
| | 177 | 295 | | || IsErrorPageNoise(logEvent) |
| | 177 | 296 | | || IsTransientOzonMethodNotAllowed(logEvent) |
| | 177 | 297 | | || IsTransientElasticsearchNoise(logEvent) |
| | 177 | 298 | | || IsOzonWebhookAvailabilityDiagnostic(logEvent) |
| | 177 | 299 | | || IsOzonWebhookRejectionNoise(logEvent) |
| | 177 | 300 | | || IsAuthRateLimitNoise(logEvent) |
| | 177 | 301 | | || IsFailedLoginAttemptNoise(logEvent) |
| | 177 | 302 | | || IsTransientSupplierServiceNoise(logEvent) |
| | 177 | 303 | | || IsArmtekDailyQuotaNoise(logEvent) |
| | 177 | 304 | | || IsExpectedBusinessOperationNoise(logEvent) |
| | 177 | 305 | | || IsSystemNotificationRejectionNoise(logEvent); |
| | | 306 | | |
| | | 307 | | /// <summary> |
| | | 308 | | /// Ограничение частоты запросов к эндпоинтам входа (/Account/Login, /Identity/Account/Login) |
| | | 309 | | /// политикой AuthRateLimiterPolicy для защиты от брутфорса (#2402). |
| | | 310 | | /// Срабатывание лимита возвращает 429 Too Many Requests — штатная защитная функция приложения, не отказ. |
| | | 311 | | /// </summary> |
| | | 312 | | public static bool IsAuthRateLimitNoise(LogEvent logEvent) |
| | | 313 | | { |
| | 95 | 314 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 34 | 315 | | return false; |
| | | 316 | | |
| | 61 | 317 | | if (HasStatusCode(logEvent, StatusCodes.Status429TooManyRequests)) |
| | | 318 | | { |
| | 2 | 319 | | var path = GetStringProperty(logEvent, "RequestPath"); |
| | 2 | 320 | | if (path is not null && (path.StartsWith("/Identity/Account/Login", StringComparison.OrdinalIgnoreCase) |
| | 2 | 321 | | || path.StartsWith("/Account/Login", StringComparison.OrdinalIgnoreCase))) |
| | | 322 | | { |
| | 2 | 323 | | return true; |
| | | 324 | | } |
| | | 325 | | } |
| | | 326 | | |
| | 59 | 327 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 59 | 328 | | return message.Contains("429", StringComparison.Ordinal) |
| | 59 | 329 | | && (message.Contains("/Identity/Account/Login", StringComparison.OrdinalIgnoreCase) |
| | 59 | 330 | | || message.Contains("/Account/Login", StringComparison.OrdinalIgnoreCase)); |
| | | 331 | | } |
| | | 332 | | |
| | | 333 | | /// <summary> |
| | | 334 | | /// Неуспешные попытки входа в систему (неверный пароль, несуществующий логин, невалидная форма) |
| | | 335 | | /// являются аудитом безопасности и действиями внешних клиентов/ботов, а не отказом приложения (#2320, #2393, #2400, |
| | | 336 | | /// Записи остаются в Seq и консоли со всем скоупом (IP, username, EventKind), но исключаются из errors-*.log. |
| | | 337 | | /// </summary> |
| | | 338 | | public static bool IsFailedLoginAttemptNoise(LogEvent logEvent) |
| | | 339 | | { |
| | 93 | 340 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 34 | 341 | | return false; |
| | | 342 | | |
| | 59 | 343 | | if (MatchesSource(logEvent, ["OzonOrdersWeb.Areas.Identity.Pages.Account.LoginModel"])) |
| | 5 | 344 | | return true; |
| | | 345 | | |
| | 54 | 346 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 54 | 347 | | if (message.StartsWith("Неуспешная попытка входа для ", StringComparison.Ordinal)) |
| | 0 | 348 | | return true; |
| | | 349 | | |
| | 54 | 350 | | var category = GetStringProperty(logEvent, "EventCategory"); |
| | 54 | 351 | | var eventKind = GetStringProperty(logEvent, "EventKind"); |
| | 54 | 352 | | if (string.Equals(category, "auth", StringComparison.OrdinalIgnoreCase) |
| | 54 | 353 | | && eventKind is "LoginUserNotFound" or "LoginInvalidPassword" or "LoginFormValidationFailed" or "LoginEmailN |
| | | 354 | | { |
| | 0 | 355 | | return true; |
| | | 356 | | } |
| | | 357 | | |
| | 54 | 358 | | return false; |
| | | 359 | | } |
| | | 360 | | |
| | | 361 | | /// <summary> |
| | | 362 | | /// Лимит клиентской телеметрии защищает endpoint от лавины одинаковых событий. Ответ 429 |
| | | 363 | | /// в этом случае ожидаем: сами события уже не нужны, а запись остаётся доступна в Seq. |
| | | 364 | | /// Другие 429 и любые исключения остаются инцидентами. |
| | | 365 | | /// </summary> |
| | | 366 | | private static bool IsClientDiagnosticsRateLimitNoise(LogEvent logEvent) |
| | 130 | 367 | | => logEvent.Exception is null |
| | 130 | 368 | | && logEvent.Level <= LogEventLevel.Warning |
| | 130 | 369 | | && MatchesSource(logEvent, [RequestObservabilitySource]) |
| | 130 | 370 | | && string.Equals(GetStringProperty(logEvent, "EventKind"), "HttpRequestFailed", StringComparison.Ordinal) |
| | 130 | 371 | | && HasStatusCode(logEvent, StatusCodes.Status429TooManyRequests) |
| | 130 | 372 | | && string.Equals(GetStringProperty(logEvent, "RequestPath"), ClientDiagnosticsPath, StringComparison.OrdinalI |
| | | 373 | | |
| | | 374 | | /// <summary> |
| | | 375 | | /// Исчерпание суточной квоты внешнего API Armtek (ws.armtek.ru). |
| | | 376 | | /// Это лимит тарифа поставщика, а не отказ Studio2 (#2350). |
| | | 377 | | /// </summary> |
| | | 378 | | public static bool IsArmtekDailyQuotaNoise(LogEvent logEvent) |
| | | 379 | | { |
| | 89 | 380 | | if (logEvent.Level > LogEventLevel.Warning) |
| | 29 | 381 | | return false; |
| | | 382 | | |
| | 60 | 383 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 60 | 384 | | var exception = logEvent.Exception?.ToString(); |
| | | 385 | | |
| | 60 | 386 | | var isQuotaMessage = ArmtekDailyQuotaGuard.IsExceeded(message) |
| | 60 | 387 | | || message.Contains("исчерпал суточную квоту", StringComparison.OrdinalIgnoreCase) |
| | 60 | 388 | | || (exception != null && (ArmtekDailyQuotaGuard.IsExceeded(exception) || exception.Contains |
| | | 389 | | |
| | 60 | 390 | | return isQuotaMessage |
| | 60 | 391 | | && (MatchesSource(logEvent, ["Services.ApiServices.ArmtekApi", "OzonOrdersWeb"]) || message.Contains("Arm |
| | | 392 | | } |
| | | 393 | | |
| | | 394 | | /// <summary> |
| | | 395 | | /// Ответ страницы ошибки (/Home/Error): отдача клиенту страницы со статусом 4xx/5xx не |
| | | 396 | | /// является самостоятельным отказом приложения. Настоящие сбои в логике рендеринга страницы ошибки |
| | | 397 | | /// несут исключение и остаются в журнале. |
| | | 398 | | /// </summary> |
| | | 399 | | public static bool IsErrorPageNoise(LogEvent logEvent) |
| | | 400 | | { |
| | 129 | 401 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 36 | 402 | | return false; |
| | | 403 | | |
| | 93 | 404 | | var path = GetStringProperty(logEvent, "RequestPath"); |
| | 93 | 405 | | if (path?.StartsWith("/Home/Error", StringComparison.OrdinalIgnoreCase) == true) |
| | 3 | 406 | | return true; |
| | | 407 | | |
| | 90 | 408 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 90 | 409 | | return message.Contains(" /Home/Error ", StringComparison.OrdinalIgnoreCase); |
| | | 410 | | } |
| | | 411 | | |
| | | 412 | | /// <summary> |
| | | 413 | | /// Ответы 405 Method Not Allowed от Ozon API (api-seller.ozon.ru, ozon.ru) при отказе метода шлюзом/прокси. |
| | | 414 | | /// </summary> |
| | | 415 | | public static bool IsTransientOzonMethodNotAllowed(LogEvent logEvent) |
| | | 416 | | { |
| | 126 | 417 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 36 | 418 | | return false; |
| | | 419 | | |
| | 90 | 420 | | if (!MatchesSource(logEvent, OutboundHttpSources)) |
| | 79 | 421 | | return false; |
| | | 422 | | |
| | 11 | 423 | | if (!logEvent.Properties.TryGetValue("StatusCode", out var value) || value is not ScalarValue { Value: 405 }) |
| | | 424 | | { |
| | 7 | 425 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 7 | 426 | | if (!message.Contains(" 405", StringComparison.Ordinal)) |
| | 7 | 427 | | return false; |
| | | 428 | | } |
| | | 429 | | |
| | 4 | 430 | | var uri = GetStringProperty(logEvent, "RequestUri"); |
| | 4 | 431 | | if (string.IsNullOrWhiteSpace(uri)) |
| | | 432 | | { |
| | 0 | 433 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 0 | 434 | | return message.Contains("api-seller.ozon.ru", StringComparison.OrdinalIgnoreCase) |
| | 0 | 435 | | || message.Contains(".ozon.ru", StringComparison.OrdinalIgnoreCase) |
| | 0 | 436 | | || message.Contains(".ozone.ru", StringComparison.OrdinalIgnoreCase); |
| | | 437 | | } |
| | | 438 | | |
| | 4 | 439 | | return uri.Contains("api-seller.ozon.ru", StringComparison.OrdinalIgnoreCase) |
| | 4 | 440 | | || uri.Contains(".ozon.ru", StringComparison.OrdinalIgnoreCase) |
| | 4 | 441 | | || uri.Contains(".ozone.ru", StringComparison.OrdinalIgnoreCase); |
| | | 442 | | } |
| | | 443 | | |
| | | 444 | | /// <summary> |
| | | 445 | | /// Запись сообщает, что операция ЗАВЕРШИЛАСЬ УСПЕШНО, но дольше порога наблюдаемости |
| | | 446 | | /// (<see cref="ObservabilityOptions.SlowRequestMilliseconds"/> и соседние). |
| | | 447 | | /// |
| | | 448 | | /// Пороги специально низкие — 1 с на внешний вызов, 3 с на запрос страницы, — потому что они |
| | | 449 | | /// задуманы как метрика для Seq: там такая запись отбирается по <c>EventKind</c> и показывает |
| | | 450 | | /// распределение по маршрутам. В errors-*.log она попадает только из-за уровня Warning, и |
| | | 451 | | /// наблюдатель заводит по ней отдельный GitHub issue на каждый URL, маршрут, SQL и джоб. |
| | | 452 | | /// Ответ поставщика за 1,1 с и страница за 3,2 с инцидентами не являются: чинить в коде нечего, |
| | | 453 | | /// сигнал самим фактом попадания в очередь инцидентов ничего не добавляет, а настоящие отказы |
| | | 454 | | /// тонут в нём. Уровень и содержимое записи не меняются: в Seq и консоли она остаётся Warning. |
| | | 455 | | /// |
| | | 456 | | /// Отсев узкий: отбрасывается только успешный исход. Отказ той же операции идёт другим |
| | | 457 | | /// <c>EventKind</c> (<c>HttpRequestFailed</c>, <c>DatabaseCommandFailed</c>) либо несёт код |
| | | 458 | | /// ответа 4xx/5xx или исключение — и остаётся в errors-*.log. |
| | | 459 | | /// </summary> |
| | | 460 | | public static bool IsSuccessfulPerformanceTelemetry(LogEvent logEvent) |
| | | 461 | | { |
| | | 462 | | // Error и Fatal ставит только ветка отказа: такую запись не прячем ни при каком EventKind. |
| | 32 | 463 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 13 | 464 | | return false; |
| | | 465 | | |
| | 19 | 466 | | return GetStringProperty(logEvent, "EventKind") switch |
| | 19 | 467 | | { |
| | 19 | 468 | | // Медленный, но не сбойный входящий запрос: статус >= 400 даёт EventKind HttpRequestFailed. |
| | 3 | 469 | | "SlowHttpRequest" => HasSuccessStatusCode(logEvent), |
| | 19 | 470 | | // Интерцептор пишет этот EventKind только для выполненной команды; ошибка идёт |
| | 19 | 471 | | // DatabaseCommandFailed уровня Error. |
| | 1 | 472 | | "SlowDatabaseCommand" => true, |
| | 19 | 473 | | // У джоба один EventKind на все исходы, поэтому сверяем шаблон конкретной ветки. |
| | 1 | 474 | | "HangfireJob" => logEvent.MessageTemplate.Text.StartsWith(SlowHangfireJobTemplate, StringComparison.Ordinal) |
| | 19 | 475 | | // Один EventKind и на медленный успех, и на ошибочный код ответа: различает статус. |
| | 9 | 476 | | "OutboundHttp" => HasSuccessStatusCode(logEvent), |
| | 5 | 477 | | _ => false |
| | 19 | 478 | | }; |
| | | 479 | | } |
| | | 480 | | |
| | | 481 | | /// <summary>Начало шаблона ветки «успешно, но дольше порога» в <see cref="HangfireObservabilityFilter"/>.</summary> |
| | | 482 | | private const string SlowHangfireJobTemplate = "Медленный Hangfire job"; |
| | | 483 | | |
| | | 484 | | /// <summary> |
| | | 485 | | /// Код ответа записан и лежит вне диапазона ошибок. Отсутствие кода трактуется как отказ: |
| | | 486 | | /// у исходящего вызова его нет только тогда, когда ответа не было вовсе. |
| | | 487 | | /// </summary> |
| | | 488 | | private static bool HasSuccessStatusCode(LogEvent logEvent) |
| | | 489 | | { |
| | 12 | 490 | | if (!logEvent.Properties.TryGetValue("StatusCode", out var value) || value is not ScalarValue scalar) |
| | 0 | 491 | | return false; |
| | | 492 | | |
| | 12 | 493 | | return scalar.Value switch |
| | 12 | 494 | | { |
| | 12 | 495 | | int statusCode => statusCode is >= 100 and < 400, |
| | 0 | 496 | | _ => false |
| | 12 | 497 | | }; |
| | | 498 | | } |
| | | 499 | | |
| | | 500 | | private static bool HasStatusCode(LogEvent logEvent, int expectedStatusCode) |
| | 83 | 501 | | => logEvent.Properties.TryGetValue("StatusCode", out var value) |
| | 83 | 502 | | && value is ScalarValue { Value: int statusCode } |
| | 83 | 503 | | && statusCode == expectedStatusCode; |
| | | 504 | | |
| | | 505 | | private static string? GetStringProperty(LogEvent logEvent, string name) |
| | 487 | 506 | | => logEvent.Properties.TryGetValue(name, out var value) && value is ScalarValue { Value: string text } |
| | 487 | 507 | | ? text |
| | 487 | 508 | | : null; |
| | | 509 | | |
| | | 510 | | private static bool IsHostingNoise(LogEvent logEvent) |
| | 160 | 511 | | => MatchesSource(logEvent, HostingNoiseSources) && ContainsAny(logEvent, HostingNoiseMessages); |
| | | 512 | | |
| | | 513 | | /// <summary> |
| | | 514 | | /// Штатное предупреждение ASP.NET Core Data Protection (XmlKeyManager) при сохранении ключей шифрования |
| | | 515 | | /// в файловую систему Linux/Docker без внешнего XML-шифратора (DPAPI/KeyVault/сертификата): |
| | | 516 | | /// "No XML encryptor configured. Key {guid} may be persisted to storage in unencrypted form." |
| | | 517 | | /// Ключи защищены правами доступа файловой системы и тома контейнера (#2325). |
| | | 518 | | /// </summary> |
| | | 519 | | private const string DataProtectionXmlKeyManagerSource = "Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyMa |
| | | 520 | | private const string DataProtectionNoEncryptorTemplate = "No XML encryptor configured"; |
| | | 521 | | |
| | | 522 | | public static bool IsDataProtectionNoEncryptorNoise(LogEvent logEvent) |
| | 157 | 523 | | => logEvent.Exception is null |
| | 157 | 524 | | && logEvent.Level <= LogEventLevel.Warning |
| | 157 | 525 | | && MatchesSource(logEvent, [DataProtectionXmlKeyManagerSource]) |
| | 157 | 526 | | && (logEvent.MessageTemplate.Text.StartsWith(DataProtectionNoEncryptorTemplate, StringComparison.Ordinal) |
| | 157 | 527 | | || logEvent.RenderMessage(CultureInfo.InvariantCulture).StartsWith(DataProtectionNoEncryptorTemplate, Str |
| | | 528 | | |
| | | 529 | | private static bool IsHangfireStorageContention(LogEvent logEvent) |
| | 177 | 530 | | => MatchesSource(logEvent, HangfireStorageSources) && ContainsAny(logEvent, TransientStorageContention); |
| | | 531 | | |
| | | 532 | | /// <summary> |
| | | 533 | | /// Штатное ожидание распределённой блокировки <c>DisableConcurrentExecution</c> завершилось |
| | | 534 | | /// успешно. Hangfire пишет его как slow log от Worker, но это метрика очереди, не отказ job. |
| | | 535 | | /// Настоящие ошибки Worker (с исключением или другим шаблоном) остаются в errors-*.log. |
| | | 536 | | /// </summary> |
| | | 537 | | private static bool IsHangfireDistributedLockWait(LogEvent logEvent) |
| | | 538 | | { |
| | 171 | 539 | | if (logEvent.Exception is not null || logEvent.Level != LogEventLevel.Warning) |
| | 57 | 540 | | return false; |
| | | 541 | | |
| | 114 | 542 | | if (!MatchesSource(logEvent, [HangfireWorkerSource])) |
| | 111 | 543 | | return false; |
| | | 544 | | |
| | 3 | 545 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 3 | 546 | | return message.Contains("DisableConcurrentExecutionAttribute", StringComparison.OrdinalIgnoreCase) |
| | 3 | 547 | | && (message.Contains("Slow log:", StringComparison.OrdinalIgnoreCase) |
| | 3 | 548 | | || message.Contains("OnPerforming", StringComparison.OrdinalIgnoreCase)); |
| | | 549 | | } |
| | | 550 | | |
| | | 551 | | /// <summary> |
| | | 552 | | /// Конкуренция за распределённую блокировку между параллельными воркерами (cannot acquire lock). |
| | | 553 | | /// </summary> |
| | | 554 | | private static bool IsHangfireDistributedLockContention(LogEvent logEvent) |
| | | 555 | | { |
| | 169 | 556 | | var matchesSource = MatchesSource(logEvent, [HangfireWorkerSource, "Hangfire.AutomaticRetryAttribute", typeof(Ha |
| | 169 | 557 | | || string.Equals(GetStringProperty(logEvent, "EventKind"), "HangfireJob", StringComparison.O |
| | | 558 | | |
| | 169 | 559 | | return matchesSource && ContainsAny(logEvent, ["cannot acquire lock", "MySqlDistributedLockException"]); |
| | | 560 | | } |
| | | 561 | | |
| | | 562 | | /// <summary> |
| | | 563 | | /// Штатная остановка воркера Hangfire при рестарте контейнера или деплое: фоновая задача |
| | | 564 | | /// возвращается в очередь и выполняется заново. Это не отказ Studio2 (#1084). Настоящее падение |
| | | 565 | | /// задачи несёт исключение и сюда не попадает. |
| | | 566 | | /// </summary> |
| | | 567 | | private const string HangfireWorkerStopTemplate = "Worker stop requested while processing background job"; |
| | | 568 | | |
| | | 569 | | private static bool IsHangfireWorkerStopRequested(LogEvent logEvent) |
| | 165 | 570 | | => logEvent.Exception is null |
| | 165 | 571 | | && logEvent.Level <= LogEventLevel.Warning |
| | 165 | 572 | | && MatchesSource(logEvent, [HangfireWorkerSource]) |
| | 165 | 573 | | && logEvent.MessageTemplate.Text.StartsWith(HangfireWorkerStopTemplate, StringComparison.Ordinal); |
| | | 574 | | |
| | | 575 | | private static bool IsRetriedEfConnectionError(LogEvent logEvent) |
| | 164 | 576 | | => MatchesSource(logEvent, ["Microsoft.EntityFrameworkCore"]) |
| | 164 | 577 | | && logEvent.MessageTemplate.Text.StartsWith(EfConnectionErrorTemplate, StringComparison.Ordinal); |
| | | 578 | | |
| | | 579 | | /// <summary> |
| | | 580 | | /// EF Core emits <c>QueryIterationFailed</c> for every failed attempt before the configured |
| | | 581 | | /// MySQL execution strategy retries it. Keep non-MySQL and non-timeout failures in the error log. |
| | | 582 | | /// </summary> |
| | | 583 | | private static bool IsRetriedEfQueryIteration(LogEvent logEvent) |
| | 162 | 584 | | => MatchesSource(logEvent, ["Microsoft.EntityFrameworkCore.Query"]) |
| | 162 | 585 | | && logEvent.MessageTemplate.Text.StartsWith(EfQueryIterationFailedTemplate, StringComparison.Ordinal) |
| | 162 | 586 | | && ContainsAny(logEvent, ["MySqlConnector.MySqlException"]) |
| | 162 | 587 | | && ContainsAny(logEvent, RetriableMySqlTimeoutMessages); |
| | | 588 | | |
| | | 589 | | /// <summary> |
| | | 590 | | /// A bare EF <c>TransactionError</c> has no exception or operation context and is a duplicate |
| | | 591 | | /// of the preceding database diagnostic. Events carrying an exception remain visible. |
| | | 592 | | /// </summary> |
| | | 593 | | private static bool IsUncorrelatedEfTransactionError(LogEvent logEvent) |
| | 161 | 594 | | => logEvent.Exception is null |
| | 161 | 595 | | && MatchesSource(logEvent, ["Microsoft.EntityFrameworkCore.Database.Transaction"]) |
| | 161 | 596 | | && string.Equals(logEvent.MessageTemplate.Text, EfTransactionErrorTemplate, StringComparison.Ordinal); |
| | | 597 | | |
| | | 598 | | private static bool IsDegradedHealthCheckNoise(LogEvent logEvent) |
| | 152 | 599 | | => MatchesSource(logEvent, HealthCheckSources) && ContainsAny(logEvent, [HealthCheckDegradedMessage]); |
| | | 600 | | |
| | | 601 | | public static bool IsBitrixConnectionNoise(LogEvent logEvent) |
| | 151 | 602 | | => logEvent.Level <= LogEventLevel.Warning |
| | 151 | 603 | | && MatchesSource(logEvent, BitrixConnectionNoiseSources) |
| | 151 | 604 | | && ContainsAny(logEvent, BitrixConnectionNoiseMessages); |
| | | 605 | | |
| | | 606 | | /// <summary> |
| | | 607 | | /// Диагностика доступности пуш-адресов в кабинетах Ozon. |
| | | 608 | | /// Контролируется джобом OzonWebhookAvailabilityJob и выводится в UI через баннер OzonSystemAlertSource |
| | | 609 | | /// и раздел /Studio2/JobLogs?tab=webhook. Внешняя недоступность или отключение адреса Ozon не является |
| | | 610 | | /// отказом Studio2: приложение продолжает работать штатно (заказы подтягиваются регулярным опросом API). |
| | | 611 | | /// Запись остаётся в Seq и консоли, но исключается из errors-*.log, чтобы не плодить ложные инциденты (#2363). |
| | | 612 | | /// </summary> |
| | | 613 | | public static bool IsOzonWebhookAvailabilityDiagnostic(LogEvent logEvent) |
| | 116 | 614 | | => MatchesSource(logEvent, ["Services.OzonWebhookServices.Monitoring.OzonWebhookAvailabilityJob"]) |
| | 116 | 615 | | && logEvent.MessageTemplate.Text.StartsWith("Пуш-уведомления Ozon недоступны", StringComparison.Ordinal); |
| | | 616 | | |
| | | 617 | | /// <summary> |
| | | 618 | | /// Штатное отклонение некорректных входящих запросов на публичный эндпоинт приёма вебхуков Ozon |
| | | 619 | | /// (/Studio2/OzonWebhook/Receive): отсутствие/невалидность токена, IP, пустой payload, не-JSON payload (#2357, #235 |
| | | 620 | | /// </summary> |
| | | 621 | | public static bool IsOzonWebhookRejectionNoise(LogEvent logEvent) |
| | | 622 | | { |
| | 117 | 623 | | if (logEvent.Level > LogEventLevel.Warning) |
| | 29 | 624 | | return false; |
| | | 625 | | |
| | 88 | 626 | | if (MatchesSource(logEvent, [RequestObservabilitySource])) |
| | | 627 | | { |
| | 24 | 628 | | var path = GetStringProperty(logEvent, "RequestPath"); |
| | 24 | 629 | | if (path?.StartsWith("/Studio2/OzonWebhook/Receive", StringComparison.OrdinalIgnoreCase) == true) |
| | | 630 | | { |
| | 6 | 631 | | if (HasStatusCode(logEvent, StatusCodes.Status400BadRequest) || HasStatusCode(logEvent, StatusCodes.Stat |
| | 6 | 632 | | return true; |
| | | 633 | | } |
| | | 634 | | } |
| | | 635 | | |
| | 82 | 636 | | if (MatchesSource(logEvent, [ |
| | 82 | 637 | | "OzonOrdersWeb.Middleware.OzonIpWhitelistFilter", |
| | 82 | 638 | | "Services.OzonWebhookServices.OzonWebhookService", |
| | 82 | 639 | | "Services.OzonWebhookServices.OzonWebhookNotificationFactory", |
| | 82 | 640 | | "OzonOrdersWeb.Areas.Studio2.Controllers.OzonWebhookController"])) |
| | | 641 | | { |
| | 15 | 642 | | return ContainsAny(logEvent, [ |
| | 15 | 643 | | "Ozon webhook: запрос отклонён", |
| | 15 | 644 | | "не входит в список разрешённых диапазонов", |
| | 15 | 645 | | "неверный или отсутствующий token", |
| | 15 | 646 | | "SharedToken не задан", |
| | 15 | 647 | | "Получен пустой payload", |
| | 15 | 648 | | "Не удалось разобрать JSON для определения типа", |
| | 15 | 649 | | "не удалось разобрать JSON payload" |
| | 15 | 650 | | ]); |
| | | 651 | | } |
| | | 652 | | |
| | 67 | 653 | | return false; |
| | | 654 | | } |
| | | 655 | | |
| | | 656 | | /// <summary> |
| | | 657 | | /// Штатное отклонение некорректных/неавторизованных входящих запросов на эндпоинты системных уведомлений деплоя |
| | | 658 | | /// (/Studio2/SystemNotification/DeployEvent): отсутствие/неверный ключ X-Api-Key, ошибки валидации формы деплоя (#2 |
| | | 659 | | /// </summary> |
| | | 660 | | public static bool IsSystemNotificationRejectionNoise(LogEvent logEvent) |
| | | 661 | | { |
| | 77 | 662 | | if (logEvent.Level > LogEventLevel.Warning) |
| | 29 | 663 | | return false; |
| | | 664 | | |
| | 48 | 665 | | if (MatchesSource(logEvent, [RequestObservabilitySource])) |
| | | 666 | | { |
| | 18 | 667 | | var path = GetStringProperty(logEvent, "RequestPath"); |
| | 18 | 668 | | if (path?.StartsWith("/Studio2/SystemNotification", StringComparison.OrdinalIgnoreCase) == true) |
| | | 669 | | { |
| | 6 | 670 | | if (HasStatusCode(logEvent, StatusCodes.Status401Unauthorized) || HasStatusCode(logEvent, StatusCodes.St |
| | 6 | 671 | | return true; |
| | | 672 | | } |
| | | 673 | | } |
| | | 674 | | |
| | 42 | 675 | | if (MatchesSource(logEvent, [ |
| | 42 | 676 | | "OzonOrdersWeb.Areas.Studio2.Controllers.SystemNotificationController", |
| | 42 | 677 | | "OzonOrdersWeb.Observability.InvalidModelStateLoggingFilter"])) |
| | | 678 | | { |
| | 12 | 679 | | return ContainsAny(logEvent, [ |
| | 12 | 680 | | "Событие деплоя с неверным ключом", |
| | 12 | 681 | | "Событие деплоя отклонено: неверный или отсутствующий ключ безопасности", |
| | 12 | 682 | | "Попытка отправки системного уведомления отклонена: неверный ключ безопасности", |
| | 12 | 683 | | "Форма POST /Studio2/SystemNotification/DeployEvent не прошла серверную валидацию", |
| | 12 | 684 | | "Форма POST /Studio2/SystemNotification" |
| | 12 | 685 | | ]); |
| | | 686 | | } |
| | | 687 | | |
| | 30 | 688 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 30 | 689 | | if (message.Contains("/Studio2/SystemNotification", StringComparison.OrdinalIgnoreCase)) |
| | | 690 | | { |
| | 0 | 691 | | if (message.Contains("401", StringComparison.Ordinal) || message.Contains("403", StringComparison.Ordinal)) |
| | 0 | 692 | | return true; |
| | 0 | 693 | | if (message.Contains("Событие деплоя", StringComparison.OrdinalIgnoreCase) |
| | 0 | 694 | | || message.Contains("системного уведомления", StringComparison.OrdinalIgnoreCase) |
| | 0 | 695 | | || message.Contains("серверную валидацию", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 696 | | return true; |
| | | 697 | | } |
| | | 698 | | |
| | 30 | 699 | | return false; |
| | | 700 | | } |
| | | 701 | | |
| | | 702 | | private static bool MatchesSource(LogEvent logEvent, string[] prefixes) |
| | | 703 | | { |
| | 3210 | 704 | | if (!logEvent.Properties.TryGetValue("SourceContext", out var value) || value is not ScalarValue scalar) |
| | 15 | 705 | | return false; |
| | 3195 | 706 | | if (scalar.Value is not string sourceContext) |
| | 0 | 707 | | return false; |
| | | 708 | | |
| | 20372 | 709 | | foreach (var prefix in prefixes) |
| | | 710 | | { |
| | 7116 | 711 | | if (sourceContext.StartsWith(prefix, StringComparison.Ordinal)) |
| | 250 | 712 | | return true; |
| | | 713 | | } |
| | | 714 | | |
| | 2945 | 715 | | return false; |
| | | 716 | | } |
| | | 717 | | |
| | | 718 | | /// <summary> |
| | | 719 | | /// Штатные бизнес-отказы операций, возвращающих HTTP 200 (например, валидация экземпляров Ozon, |
| | | 720 | | /// печать этикеток или проведение), когда отказ является штатным ответом пользователю (#1418, #1813, #2134, #2383). |
| | | 721 | | /// </summary> |
| | | 722 | | public static bool IsExpectedBusinessOperationNoise(LogEvent logEvent) |
| | | 723 | | { |
| | 83 | 724 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 34 | 725 | | return false; |
| | | 726 | | |
| | 49 | 727 | | if (!MatchesSource(logEvent, ["OzonOrdersWeb.Observability.BusinessFailureLoggingFilter"])) |
| | 35 | 728 | | return false; |
| | | 729 | | |
| | 14 | 730 | | var path = GetStringProperty(logEvent, "RequestPath"); |
| | 14 | 731 | | if (string.IsNullOrEmpty(path)) |
| | 0 | 732 | | return false; |
| | | 733 | | |
| | 14 | 734 | | return BusinessFailureLoggingFilter.IsExpectedFailurePath(path); |
| | | 735 | | } |
| | | 736 | | |
| | | 737 | | /// <summary> |
| | | 738 | | /// Hangfire кладёт причину то в текст записи, то в приложенное исключение, поэтому смотрим оба. |
| | | 739 | | /// </summary> |
| | | 740 | | private static bool ContainsAny(LogEvent logEvent, string[] needles) |
| | | 741 | | { |
| | 106 | 742 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 106 | 743 | | var exception = logEvent.Exception?.ToString(); |
| | | 744 | | |
| | 705 | 745 | | foreach (var needle in needles) |
| | | 746 | | { |
| | 282 | 747 | | if (message.Contains(needle, StringComparison.Ordinal)) |
| | 65 | 748 | | return true; |
| | 217 | 749 | | if (exception is not null && exception.Contains(needle, StringComparison.Ordinal)) |
| | 6 | 750 | | return true; |
| | | 751 | | } |
| | | 752 | | |
| | 35 | 753 | | return false; |
| | | 754 | | } |
| | | 755 | | |
| | 1 | 756 | | private static readonly string[] SupplierHosts = |
| | 1 | 757 | | [ |
| | 1 | 758 | | "armtek.ru", |
| | 1 | 759 | | "armtek.by", |
| | 1 | 760 | | "forum-auto.ru", |
| | 1 | 761 | | "ml-auto.ru", |
| | 1 | 762 | | "ml-auto.by", |
| | 1 | 763 | | "motexc.by", |
| | 1 | 764 | | "profit-liga.ru", |
| | 1 | 765 | | "favorit-parts.ru", |
| | 1 | 766 | | "moskvorechie.ru", |
| | 1 | 767 | | "berg.ru", |
| | 1 | 768 | | "shate-m.by", |
| | 1 | 769 | | "japarts.ru", |
| | 1 | 770 | | "nikei.ru", |
| | 1 | 771 | | "avdmotors.ru", |
| | 1 | 772 | | "mikado-parts.ru", |
| | 1 | 773 | | "zzap.ru", |
| | 1 | 774 | | "zzap.pro" |
| | 1 | 775 | | ]; |
| | | 776 | | |
| | | 777 | | /// <summary> |
| | | 778 | | /// Временные сбои сервисов внешних поставщиков (HTTP 500, 502, 503, 504 при временной недоступности стороннего серв |
| | | 779 | | /// В OutboundRequestLoggingHandler такие ответы логируются на уровне Information (#2098, #2173, #2307). |
| | | 780 | | /// Фильтр страхует от попадания в errors-*.log предупреждений внешних сервисов при сетевых сбоях третьих сторон. |
| | | 781 | | /// </summary> |
| | | 782 | | public static bool IsTransientSupplierServiceNoise(LogEvent logEvent) |
| | | 783 | | { |
| | 92 | 784 | | if (logEvent.Exception is not null || logEvent.Level > LogEventLevel.Warning) |
| | 35 | 785 | | return false; |
| | | 786 | | |
| | 57 | 787 | | if (!MatchesSource(logEvent, OutboundHttpSources)) |
| | 46 | 788 | | return false; |
| | | 789 | | |
| | 11 | 790 | | var is5xx = false; |
| | 11 | 791 | | if (logEvent.Properties.TryGetValue("StatusCode", out var value) && value is ScalarValue { Value: int code and ( |
| | | 792 | | { |
| | 8 | 793 | | is5xx = true; |
| | | 794 | | } |
| | | 795 | | else |
| | | 796 | | { |
| | 3 | 797 | | var message = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | 3 | 798 | | if (message.Contains("вернул 500", StringComparison.Ordinal) || |
| | 3 | 799 | | message.Contains("вернул 502", StringComparison.Ordinal) || |
| | 3 | 800 | | message.Contains("вернул 503", StringComparison.Ordinal) || |
| | 3 | 801 | | message.Contains("вернул 504", StringComparison.Ordinal)) |
| | | 802 | | { |
| | 0 | 803 | | is5xx = true; |
| | | 804 | | } |
| | | 805 | | } |
| | | 806 | | |
| | 11 | 807 | | if (!is5xx) |
| | 3 | 808 | | return false; |
| | | 809 | | |
| | 8 | 810 | | var uri = GetStringProperty(logEvent, "RequestUri"); |
| | 8 | 811 | | var host = GetStringProperty(logEvent, "DependencyHost"); |
| | 8 | 812 | | var fullText = (uri ?? string.Empty) + " " + (host ?? string.Empty); |
| | 8 | 813 | | if (string.IsNullOrWhiteSpace(fullText.Trim())) |
| | 0 | 814 | | fullText = logEvent.RenderMessage(CultureInfo.InvariantCulture); |
| | | 815 | | |
| | 123 | 816 | | return SupplierHosts.Any(h => fullText.Contains(h, StringComparison.OrdinalIgnoreCase)); |
| | | 817 | | } |
| | | 818 | | } |