< Summary

Information
Class: OzonOrdersWeb.Observability.BusinessFailureLoggingFilter
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/BusinessFailureLoggingFilter.cs
Line coverage
61%
Covered lines: 76
Uncovered lines: 48
Coverable lines: 124
Total lines: 211
Line coverage: 61.2%
Branch coverage
42%
Covered branches: 56
Total branches: 132
Branch coverage: 42.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
IsExpectedFailurePath(...)90%101088.88%
OnResultExecutionAsync()78.57%141495.83%
GetStatusCode(...)50%5466.66%
TryFindFailure(...)11.11%2141815.38%
TryReadFailure(...)73.33%483072.72%
TryReadDictionaryFailure(...)0%702260%
ReadDictKey(...)0%272160%
ReadProperty(...)71.42%141488.88%

File(s)

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

#LineLine coverage
 1using System.Collections;
 2using System.Reflection;
 3using Microsoft.AspNetCore.Mvc;
 4using Microsoft.AspNetCore.Mvc.Filters;
 5using Microsoft.Extensions.Options;
 6
 7namespace OzonOrdersWeb.Observability;
 8
 9/// <summary>
 10/// Фиксирует legacy-операции, которые сообщают об ошибке JSON/TempData, но возвращают HTTP 200.
 11/// Такие ответы не видны middleware по HTTP status и иначе не попадают в общий реестр проблем.
 12/// </summary>
 713public sealed class BusinessFailureLoggingFilter(
 714    ILogger<BusinessFailureLoggingFilter> logger,
 715    SensitiveDataRedactor redactor,
 716    IOptions<ObservabilityOptions> options) : IAsyncResultFilter
 17{
 18    public const string LoggedItemKey = "__studio2_business_failure_logged";
 719    private readonly ObservabilityOptions _options = options.Value;
 20
 121    private static readonly HashSet<string> ExcludedPaths = new(StringComparer.OrdinalIgnoreCase)
 122    {
 123        "/Studio2/OzonChat/GetTotalUnreadCount"
 124    };
 25
 26    /// <summary>
 27    /// Операции, где отказ — штатный ответ пользователю, а не сбой (#1418, #1813, #2134, #2383):
 28    /// Ozon не выдаёт этикетки для отменённых/отгруженных отправлений, количество расходится с Ozon,
 29    /// данные экземпляров маркировки не подтверждены к сборке Ozon (SetOzonExemplars);
 30    /// проведение транзакции TransactionsGrid само логирует остановку шага (TransactionCommitService).
 31    /// Для них запись остаётся в Seq уровнем Information и не заводит инцидент.
 32    /// </summary>
 133    private static readonly HashSet<string> ExpectedFailurePaths = new(StringComparer.OrdinalIgnoreCase)
 134    {
 135        "/Studio2/Orders/UpdateOrdersStatus",
 136        "/Studio2/Orders/GenerateLabels",
 137        "/Studio2/Orders/SetOzonExemplars"
 138    };
 39
 40    internal static bool IsExpectedFailurePath(string? path)
 41    {
 2542        if (string.IsNullOrEmpty(path))
 043            return false;
 2544        if (ExpectedFailurePaths.Contains(path))
 1445            return true;
 46
 47        // /Studio2/TransactionsGrid/{slug}/Commit
 1148        var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
 1149        return segments.Length == 4
 1150               && segments[0].Equals("Studio2", StringComparison.OrdinalIgnoreCase)
 1151               && segments[1].Equals("TransactionsGrid", StringComparison.OrdinalIgnoreCase)
 1152               && segments[3].Equals("Commit", StringComparison.OrdinalIgnoreCase);
 53    }
 54
 55    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
 56    {
 757        var path = context.HttpContext.Request.Path.Value;
 758        if (path != null && ExcludedPaths.Contains(path))
 59        {
 060            context.HttpContext.Items[LoggedItemKey] = true;
 61        }
 62
 763        if (!context.HttpContext.Items.ContainsKey(LoggedItemKey)
 764            && TryFindFailure(context, out var message))
 65        {
 766            context.HttpContext.Items[LoggedItemKey] = true;
 767            var routeData = context.RouteData;
 768            using var scope = logger.BeginScope(new Dictionary<string, object?>
 769            {
 770                ["EventKind"] = "BusinessOperationFailed",
 771                ["EventCategory"] = "backend",
 772                ["RequestPath"] = context.HttpContext.Request.Path.Value,
 773                ["HttpMethod"] = context.HttpContext.Request.Method,
 774                ["StatusCode"] = GetStatusCode(context.Result),
 775                ["Controller"] = routeData.Values["controller"]?.ToString(),
 776                ["Action"] = routeData.Values["action"]?.ToString(),
 777                ["TraceId"] = context.HttpContext.TraceIdentifier,
 778                ["BusinessError"] = redactor.Redact(message, _options.MaxPayloadCharacters)
 779            });
 780            logger.Log(IsExpectedFailurePath(path) ? LogLevel.Information : LogLevel.Warning,
 781                "Операция {Method} {Path} сообщила о бизнес-ошибке при успешном HTTP-ответе",
 782                context.HttpContext.Request.Method, context.HttpContext.Request.Path);
 83        }
 84
 785        await next();
 786    }
 87
 788    private static int GetStatusCode(IActionResult result) => result switch
 789    {
 090        ObjectResult objectResult => objectResult.StatusCode ?? StatusCodes.Status200OK,
 091        StatusCodeResult statusCodeResult => statusCodeResult.StatusCode,
 792        _ => StatusCodes.Status200OK
 793    };
 94
 95    private static bool TryFindFailure(ResultExecutingContext context, out string message)
 96    {
 797        if (context.Result is JsonResult jsonResult && TryReadFailure(jsonResult.Value, out message))
 98        {
 799            return true;
 100        }
 101
 0102        if (context.Result is ObjectResult objectResult && TryReadFailure(objectResult.Value, out message))
 103        {
 0104            return true;
 105        }
 106
 0107        if (context.Controller is Controller controller)
 108        {
 0109            foreach (var key in new[] { "Error", "ErrorMessage", "ErrorSummary" })
 110            {
 0111                if (controller.TempData.TryGetValue(key, out var value)
 0112                    && value is not null
 0113                    && !string.IsNullOrWhiteSpace(value.ToString()))
 114                {
 0115                    message = value.ToString()!;
 0116                    return true;
 117                }
 118            }
 119        }
 120
 0121        message = string.Empty;
 0122        return false;
 123    }
 124
 125    private static bool TryReadFailure(object? value, out string message)
 126    {
 7127        if (value is null)
 128        {
 0129            message = string.Empty;
 0130            return false;
 131        }
 132
 7133        if (value is IDictionary dictionary)
 134        {
 0135            return TryReadDictionaryFailure(dictionary, out message);
 136        }
 137
 7138        var type = value.GetType();
 7139        var success = type.GetProperty("Success", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
 7140        if (success?.PropertyType != typeof(bool) || success.GetValue(value) is not false)
 141        {
 0142            message = string.Empty;
 0143            return false;
 144        }
 145
 7146        var baseMessage = ReadProperty(value, type, "Error")
 7147                          ?? ReadProperty(value, type, "Message");
 7148        var details = ReadProperty(value, type, "Errors")
 7149                      ?? ReadProperty(value, type, "Reasons");
 150
 7151        message = (baseMessage, details) switch
 7152        {
 2153            ({ Length: > 0 } m, { Length: > 0 } d) when !m.Contains(d, StringComparison.OrdinalIgnoreCase) => $"{m}: {d}
 1154            ({ Length: > 0 } m, _) => m,
 5155            (_, { Length: > 0 } d) => d,
 0156            _ => "Операция вернула Success=false"
 7157        };
 7158        return true;
 159    }
 160
 161    private static bool TryReadDictionaryFailure(IDictionary dictionary, out string message)
 162    {
 0163        object? success = dictionary.Keys.Cast<object?>()
 0164            .FirstOrDefault(key => string.Equals(key?.ToString(), "success", StringComparison.OrdinalIgnoreCase));
 0165        if (success is null || dictionary[success] is not false)
 166        {
 0167            message = string.Empty;
 0168            return false;
 169        }
 170
 0171        string? baseMsg = ReadDictKey(dictionary, "error") ?? ReadDictKey(dictionary, "message");
 0172        string? details = ReadDictKey(dictionary, "errors") ?? ReadDictKey(dictionary, "reasons");
 173
 0174        message = (baseMsg, details) switch
 0175        {
 0176            ({ Length: > 0 } m, { Length: > 0 } d) when !m.Contains(d, StringComparison.OrdinalIgnoreCase) => $"{m}: {d}
 0177            ({ Length: > 0 } m, _) => m,
 0178            (_, { Length: > 0 } d) => d,
 0179            _ => "Операция вернула success=false"
 0180        };
 0181        return true;
 182    }
 183
 184    private static string? ReadDictKey(IDictionary dict, string name)
 185    {
 0186        var key = dict.Keys.Cast<object?>()
 0187            .FirstOrDefault(k => string.Equals(k?.ToString(), name, StringComparison.OrdinalIgnoreCase));
 0188        if (key is null || dict[key] is not { } val)
 0189            return null;
 190
 0191        return val switch
 0192        {
 0193            string s => s,
 0194            IEnumerable items => string.Join("; ", items.Cast<object?>().Select(i => i?.ToString())
 0195                .Where(i => !string.IsNullOrWhiteSpace(i))) is { Length: > 0 } joined ? joined : null,
 0196            var other => other.ToString()
 0197        };
 198    }
 199
 200    // Коллекции (Errors = List<string>) склеиваются: ToString() давал «System.Collections.Generic.List`1[...]».
 201    private static string? ReadProperty(object value, Type type, string name) =>
 23202        type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
 23203            ?.GetValue(value) switch
 23204        {
 15205            null => null,
 2206            string text => text,
 18207            IEnumerable items => string.Join("; ", items.Cast<object?>().Select(item => item?.ToString())
 18208                .Where(item => !string.IsNullOrWhiteSpace(item))) is { Length: > 0 } joined ? joined : null,
 0209            var other => other.ToString()
 23210        };
 211}