< Summary

Information
Class: OzonOrdersWeb.Observability.ClientTelemetryEvent
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/ClientTelemetryController.cs
Line coverage
100%
Covered lines: 10
Uncovered lines: 0
Coverable lines: 10
Total lines: 111
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Kind()100%11100%
get_Message()100%11100%
get_Stack()100%11100%
get_PageUrl()100%11100%
get_TargetUrl()100%11100%
get_TraceId()100%11100%
get_StatusCode()100%11100%
get_ElapsedMilliseconds()100%11100%
get_FrontendVersion()100%11100%
get_UserAction()100%11100%

File(s)

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

#LineLine coverage
 1using System.ComponentModel.DataAnnotations;
 2using System.Security.Claims;
 3using Microsoft.AspNetCore.Authorization;
 4using Microsoft.AspNetCore.Mvc;
 5using Microsoft.AspNetCore.RateLimiting;
 6using Microsoft.Extensions.Options;
 7using OzonOrdersWeb.Build;
 8
 9namespace OzonOrdersWeb.Observability;
 10
 11[ApiController]
 12[AllowAnonymous]
 13[IgnoreAntiforgeryToken]
 14[EnableRateLimiting("client-diagnostics")]
 15[Route("api/diagnostics/client-event")]
 16public sealed class ClientTelemetryController(
 17    ILogger<ClientTelemetryController> logger,
 18    SensitiveDataRedactor redactor,
 19    IOptions<ObservabilityOptions> options) : ControllerBase
 20{
 21    private readonly ObservabilityOptions _options = options.Value;
 22
 23    [HttpGet]
 24    public IActionResult Get() => NoContent();
 25
 26    [HttpPost]
 27    public IActionResult Post([FromBody] ClientTelemetryEvent input)
 28    {
 29        var origin = Request.Headers.Origin.ToString();
 30        if (!Uri.TryCreate(origin, UriKind.Absolute, out var originUri)
 31            || !string.Equals(originUri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase))
 32        {
 33            // Не даём внешнему источнику превращать endpoint в генератор GitHub Issues.
 34            return NoContent();
 35        }
 36
 37        if (string.Equals(input.Kind, "ResourceLoadError", StringComparison.OrdinalIgnoreCase)
 38            && (input.Message.Contains("IMG", StringComparison.OrdinalIgnoreCase)
 39                || input.Message.Contains("VIDEO", StringComparison.OrdinalIgnoreCase)
 40                || input.Message.Contains("AUDIO", StringComparison.OrdinalIgnoreCase)))
 41        {
 42            return NoContent();
 43        }
 44
 45        var page = redactor.Redact(input.PageUrl, _options.MaxPayloadCharacters);
 46        var target = redactor.Redact(input.TargetUrl, _options.MaxPayloadCharacters);
 47        var message = redactor.Redact(input.Message, _options.MaxPayloadCharacters);
 48        var stack = redactor.Redact(input.Stack, _options.MaxPayloadCharacters);
 49
 50        using var scope = logger.BeginScope(new Dictionary<string, object?>
 51        {
 52            ["EventKind"] = string.IsNullOrWhiteSpace(input.Kind) ? "FrontendError" : input.Kind,
 53            ["EventCategory"] = "frontend",
 54            ["PageUrl"] = page,
 55            ["RequestUri"] = target,
 56            ["StatusCode"] = input.StatusCode,
 57            ["ElapsedMilliseconds"] = input.ElapsedMilliseconds,
 58            ["FrontendStack"] = stack,
 59            ["Browser"] = redactor.Redact(Request.Headers.UserAgent.ToString(), 1_024),
 60            ["FrontendVersion"] = redactor.Redact(input.FrontendVersion, 256),
 61            ["ApplicationVersion"] = AppBuildInfo.CommitHash,
 62            ["ApplicationBuildDate"] = AppBuildInfo.CommitDate,
 63            ["UserAction"] = redactor.Redact(input.UserAction, 1_024),
 64            ["TraceId"] = string.IsNullOrWhiteSpace(input.TraceId) ? HttpContext.TraceIdentifier : input.TraceId,
 65            ["UserId"] = User.FindFirstValue(ClaimTypes.NameIdentifier),
 66            ["RemoteIp"] = HttpContext.Connection.RemoteIpAddress?.ToString()
 67        });
 68
 69        if (FrontendOutageNoiseClassifier.IsServerOutageNoise(input))
 70        {
 71            // Недоступность самого Studio2 (деплой, 502 от Nginx, обрыв соединения) дефектом
 72            // фронтенда не является: в Seq запись остаётся, в errors-*.log — нет, и наблюдатель
 73            // логов не заводит по ней issue на каждый упавший запрос.
 74            logger.LogInformation("Недоступность сервера в клиенте {Kind} на {Page}: {Message}", input.Kind, page, messa
 75            return Accepted();
 76        }
 77
 78        logger.LogWarning("Ошибка frontend {Kind} на {Page}: {Message}", input.Kind, page, message);
 79        return Accepted();
 80    }
 81}
 82
 83public sealed class ClientTelemetryEvent
 84{
 85    [Required, StringLength(100)]
 23286    public string Kind { get; init; } = "FrontendError";
 87
 88    [Required, StringLength(16_384)]
 22689    public string Message { get; init; } = string.Empty;
 90
 91    [StringLength(16_384)]
 3392    public string? Stack { get; init; }
 93
 94    [StringLength(4_096)]
 6795    public string? PageUrl { get; init; }
 96
 97    [StringLength(4_096)]
 4598    public string? TargetUrl { get; init; }
 99
 100    [StringLength(256)]
 33101    public string? TraceId { get; init; }
 102
 96103    public int? StatusCode { get; init; }
 33104    public long? ElapsedMilliseconds { get; init; }
 105
 106    [StringLength(256)]
 33107    public string? FrontendVersion { get; init; }
 108
 109    [StringLength(1_024)]
 33110    public string? UserAction { get; init; }
 111}