< Summary

Information
Class: OzonOrdersWeb.Observability.FrontendOutageNoiseClassifier
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/FrontendOutageNoiseClassifier.cs
Line coverage
94%
Covered lines: 93
Uncovered lines: 5
Coverable lines: 98
Total lines: 204
Line coverage: 94.8%
Branch coverage
89%
Covered branches: 61
Total branches: 68
Branch coverage: 89.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
IsServerOutageNoise(...)100%1414100%
IsWarmupNoise(...)83.33%66100%
IsBadgeHtmlResponseNoise(...)91.66%121290%
MentionsGatewayStatus(...)50%22100%
IsGatewayFailure(...)100%22100%
IsConnectionLossKind(...)83.33%6683.33%
ContainsConnectionLossMessage(...)83.33%6683.33%
IsSignalRTransientNoise(...)83.33%6683.33%
IsBenignClientNoise(...)92.85%141491.66%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/FrontendOutageNoiseClassifier.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2
 3namespace OzonOrdersWeb.Observability;
 4
 5/// <summary>
 6/// Отделяет события клиентской телеметрии, описывающие недоступность самого Studio2 или обрыв
 7/// сети у браузера, от настоящих дефектов фронтенда.
 8///
 9/// Во время деплоя контейнер перезапускается: часть запросов получает 502/503/504 от Nginx,
 10/// часть обрывается на уже закрытом соединении и приходит в браузер как «сеть недоступна»
 11/// (<c>XMLHttpRequest.status === 0</c>, <c>TypeError: Failed to fetch</c>). Для пользователя это
 12/// покрыто модалкой <c>window.ServerAvailability</c>, для эксплуатации — health-мониторингом
 13/// инстанса. Исправлять в коде фронтенда тут нечего, а <c>Scripts/log-watcher/studio2_log_watcher.py</c>
 14/// заводит GitHub issue на каждую запись уровня Warning в <c>errors-*.log</c> — по одной на каждый
 15/// упавший запрос.
 16///
 17/// Поэтому такие события остаются в Seq и консоли (уровнем Information, всплеск по-прежнему виден),
 18/// но не попадают в журнал отказов и не превращаются в issue. Правила намеренно узкие: смотрим
 19/// либо HTTP-статус шлюза, либо тип события об обрыве соединения, либо явный текст сетевого сбоя.
 20/// </summary>
 21public static class FrontendOutageNoiseClassifier
 22{
 23    /// <summary>Ответы Nginx, когда приложение за ним не отвечает или ещё поднимается.</summary>
 124    private static readonly int[] GatewayStatusCodes = [502, 503, 504];
 25
 26    /// <summary>
 27    /// Виды событий, которые клиентская телеметрия отправляет именно при обрыве соединения:
 28    /// у XHR при этом <c>status === 0</c>, у fetch — <c>TypeError</c> без ответа.
 29    /// </summary>
 130    private static readonly string[] ConnectionLossKinds =
 131    [
 132        "AjaxNetworkError",
 133        "FetchNetworkError",
 134        "BrowserOffline"
 135    ];
 36
 37    /// <summary>
 38    /// Формулировки браузеров и прикладного кода для «до сервера не дошли». Список закрытый:
 39    /// прикладная ошибка с собственным текстом сюда не попадает и остаётся отказом Studio2.
 40    /// </summary>
 141    private static readonly string[] ConnectionLossMessages =
 142    [
 143        "Failed to fetch",
 144        "NetworkError when attempting to fetch resource",
 145        "The network connection was lost",
 146        "Load failed",
 147        "Нет соединения с сервером",
 148        "net::ERR_",
 149        "ERR_NETWORK",
 150        "ERR_CONNECTION"
 151    ];
 52
 53    /// <summary>
 54    /// Прикладной код нередко ловит ответ шлюза сам и пишет его в консоль своим текстом
 55    /// («Bitrix: HTTP 502 error», «GetLatestSnapshot HTTP error: 502»). Тогда статус приходит
 56    /// только внутри сообщения, поэтому распознаём его и там.
 57    /// </summary>
 158    private static readonly Regex GatewayStatusInMessage = new(
 159        @"HTTP\D{0,12}(502|503|504)\b",
 160        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
 161        TimeSpan.FromMilliseconds(100));
 62
 63    /// <summary>
 64    /// Сообщения библиотеки SignalR и прикладных обработчиков при штатном переподключении,
 65    /// таймауте рукопожатия или смене транспорта.
 66    /// </summary>
 167    private static readonly string[] SignalRTransientMessages =
 168    [
 169        "Handshake was canceled",
 170        "Server returned handshake error",
 171        "Timeout from HTTP request",
 172        "The page is being frozen",
 173        "SignalR connection transient error",
 174        "Transient SignalR connection error",
 175        "WebSocket closed with status code: 1006",
 176        "Failed to complete negotiation with the server",
 177        "Failed to start the transport"
 178    ];
 79
 80    /// <summary>
 81    /// Известный некритичный шум фронтенда (ResizeObserver в сторонних библиотеках вроде AG Grid,
 82    /// штатные ошибки валидации при пакетной печати этикеток, отмена диалогов пользователем и т.п.).
 83    /// </summary>
 184    private static readonly string[] BenignClientNoiseMessages =
 185    [
 186        "ResizeObserver loop completed with undelivered notifications",
 187        "ResizeObserver loop limit exceeded",
 188        "Multi-order print error:",
 189        "Отменено пользователем",
 190        "AG Grid: warning #2",
 191        "ag-grid.com/javascript-data-grid/errors/2"
 192    ];
 93
 94    /// <summary>
 95    /// Событие описывает недоступность сервера, обрыв сети или известный некритичный шум фронтенда.
 96    /// </summary>
 97    public static bool IsServerOutageNoise(ClientTelemetryEvent input)
 3398        => IsGatewayFailure(input.StatusCode)
 3399           || IsConnectionLossKind(input.Kind)
 33100           || ContainsConnectionLossMessage(input.Message)
 33101           || MentionsGatewayStatus(input.Message)
 33102           || IsBadgeHtmlResponseNoise(input.Message)
 33103           || IsSignalRTransientNoise(input.Message)
 33104           || IsBenignClientNoise(input.Message)
 33105           || IsWarmupNoise(input);
 106
 107    private static bool IsWarmupNoise(ClientTelemetryEvent input)
 108    {
 9109        var target = input.TargetUrl;
 9110        if (!string.IsNullOrWhiteSpace(target) && target.Contains("/warmup", StringComparison.OrdinalIgnoreCase))
 2111            return true;
 112
 7113        var message = input.Message;
 7114        return !string.IsNullOrWhiteSpace(message) && message.Contains("/warmup", StringComparison.OrdinalIgnoreCase);
 115    }
 116
 117    private static bool IsBadgeHtmlResponseNoise(string? message)
 118    {
 23119        if (string.IsNullOrWhiteSpace(message))
 0120            return false;
 121
 23122        var isJsonHtmlMismatch = message.Contains("is not valid JSON", StringComparison.OrdinalIgnoreCase)
 23123                                 || message.Contains("Unexpected token '<'", StringComparison.OrdinalIgnoreCase);
 124
 23125        if (!isJsonHtmlMismatch)
 20126            return false;
 127
 3128        return message.Contains("FidStat anomaly badge", StringComparison.OrdinalIgnoreCase)
 3129               || message.Contains("непрочитанных сообщений", StringComparison.OrdinalIgnoreCase)
 3130               || message.Contains("GetLatestSnapshot", StringComparison.OrdinalIgnoreCase)
 3131               || message.Contains("GetTotalUnreadCount", StringComparison.OrdinalIgnoreCase);
 132    }
 133
 134    private static bool MentionsGatewayStatus(string? message)
 26135        => !string.IsNullOrWhiteSpace(message) && GatewayStatusInMessage.IsMatch(message);
 136
 137    private static bool IsGatewayFailure(int? statusCode)
 33138        => statusCode is not null && Array.IndexOf(GatewayStatusCodes, statusCode.Value) >= 0;
 139
 140    private static bool IsConnectionLossKind(string? kind)
 141    {
 31142        if (string.IsNullOrWhiteSpace(kind))
 0143            return false;
 144
 239145        foreach (var known in ConnectionLossKinds)
 146        {
 90147            if (string.Equals(kind, known, StringComparison.OrdinalIgnoreCase))
 3148                return true;
 149        }
 150
 28151        return false;
 152    }
 153
 154    private static bool ContainsConnectionLossMessage(string? message)
 155    {
 28156        if (string.IsNullOrWhiteSpace(message))
 0157            return false;
 158
 482159        foreach (var marker in ConnectionLossMessages)
 160        {
 214161            if (message.Contains(marker, StringComparison.OrdinalIgnoreCase))
 2162                return true;
 163        }
 164
 26165        return false;
 166    }
 167
 168    private static bool IsSignalRTransientNoise(string? message)
 169    {
 21170        if (string.IsNullOrWhiteSpace(message))
 0171            return false;
 172
 349173        foreach (var marker in SignalRTransientMessages)
 174        {
 156175            if (message.Contains(marker, StringComparison.OrdinalIgnoreCase))
 5176                return true;
 177        }
 178
 16179        return false;
 180    }
 181
 182    private static bool IsBenignClientNoise(string? message)
 183    {
 16184        if (string.IsNullOrWhiteSpace(message))
 0185            return false;
 186
 16187        var trimmed = message.Trim();
 16188        if (string.Equals(trimmed, "cancel", StringComparison.OrdinalIgnoreCase)
 16189            || string.Equals(trimmed, "canceled", StringComparison.OrdinalIgnoreCase)
 16190            || string.Equals(trimmed, "cancelled", StringComparison.OrdinalIgnoreCase)
 16191            || string.Equals(trimmed, "Error: cancel", StringComparison.OrdinalIgnoreCase))
 192        {
 1193            return true;
 194        }
 195
 178196        foreach (var marker in BenignClientNoiseMessages)
 197        {
 77198            if (message.Contains(marker, StringComparison.OrdinalIgnoreCase))
 6199                return true;
 200        }
 201
 9202        return false;
 203    }
 204}