shoc-backend/Api.SeaHavenIndustries/Observability/SentryObservability.cs
2026-09-15 16:08:14 -03:00

297 lines
9.8 KiB
C#

using System.Reflection;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Sentry;
namespace Api.SeaHavenIndustries.Observability;
public static class SentryObservability
{
public const string ServiceName = "shoc-backend";
public const string LocalDevelopmentRelease = "local-development";
private const string ReleasePrefix = ServiceName + "@";
private const int CommitShaLength = 40;
public static string ResolveRelease(Assembly? assembly)
{
var informationalVersion = assembly?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
return ResolveRelease(informationalVersion);
}
public static string? ResolveCommitSha(Assembly? assembly)
{
var informationalVersion = assembly?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
return ResolveCommitSha(informationalVersion);
}
public static string ResolveRelease(string? informationalVersion) =>
TryResolveCommitSha(informationalVersion, out var commitSha)
? ReleasePrefix + commitSha
: LocalDevelopmentRelease;
public static string? ResolveCommitSha(string? informationalVersion) =>
TryResolveCommitSha(informationalVersion, out var commitSha)
? commitSha
: null;
public static bool IsReleaseInformationalVersion(string? value) =>
TryResolveCommitSha(value, out _);
private static bool TryResolveCommitSha(string? value, out string? commitSha)
{
commitSha = null;
if (value is null || !value.StartsWith(ReleasePrefix, StringComparison.Ordinal))
return false;
var body = value[ReleasePrefix.Length..];
var suffixSeparatorIndex = body.IndexOf('+');
var sha = suffixSeparatorIndex < 0 ? body : body[..suffixSeparatorIndex];
if (sha.Length != CommitShaLength || !sha.All(IsCommitShaHexDigit))
return false;
if (suffixSeparatorIndex >= 0
&& !string.Equals(body[(suffixSeparatorIndex + 1)..], sha, StringComparison.OrdinalIgnoreCase))
return false;
commitSha = sha.ToLowerInvariant();
return true;
}
private static bool IsCommitShaHexDigit(char c) =>
c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
public static void ConfigureRequest(IHub hub, HttpContext context)
{
var endpoint = context.GetEndpoint();
var routeEndpoint = endpoint as RouteEndpoint;
var routeTemplate = routeEndpoint?.RoutePattern.RawText;
var actionDescriptor = endpoint?.Metadata.GetMetadata<ControllerActionDescriptor>();
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var authenticated = !string.IsNullOrEmpty(userId);
hub.ConfigureScope(scope =>
{
if (authenticated)
scope.User = new SentryUser { Id = userId };
scope.SetTag("actor.type", authenticated ? "authenticated" : "anonymous");
scope.SetTag("operation.type", "http.server");
if (actionDescriptor is not null)
scope.SetTag("code.function", $"{actionDescriptor.ControllerName}.{actionDescriptor.ActionName}");
scope.SetTag("http.method", context.Request.Method);
if (!string.IsNullOrEmpty(routeTemplate))
scope.SetTag("http.route", routeTemplate);
var activeSpan = hub.GetSpan();
if (activeSpan is not null)
{
scope.SetTag("trace_id", activeSpan.TraceId.ToString());
scope.SetTag("transaction_id", activeSpan.SpanId.ToString());
}
});
}
public static SentryBackgroundTransactionHandle BeginBackgroundTransaction(
IHub hub,
string name,
string function,
string? description = null)
{
var scope = hub.PushScope();
var transaction = hub.StartTransaction(name, "task");
if (!string.IsNullOrEmpty(description))
transaction.Description = description;
hub.ConfigureScope(s => s.Transaction = transaction);
foreach (var (key, value) in new[]
{
("actor.type", "system"),
("operation.type", "background_job"),
("code.function", function),
("trace_id", transaction.TraceId.ToString()),
("transaction_id", transaction.SpanId.ToString()),
})
{
transaction.SetTag(key, value);
hub.ConfigureScope(s => s.SetTag(key, value));
}
return new SentryBackgroundTransactionHandle(transaction, scope);
}
}
public static class SentryTelemetryScrubber
{
private static readonly HashSet<string> SafeSpanDataKeys = new(StringComparer.Ordinal)
{
"actor.type",
"code.function",
"db.operation.name",
"db.system",
"http.method",
"http.request.method",
"http.response.status_code",
"http.route",
"http.status_code",
"network.protocol.version",
"operation.type",
"server.address",
"url.scheme",
};
private static readonly HashSet<string> SafeSpanTagKeys = new(StringComparer.Ordinal)
{
"actor.type",
"code.function",
"http.method",
"http.route",
"operation.type",
};
public static SentryEvent Scrub(SentryEvent @event)
{
ScrubRequest(@event.Request);
@event.User = KeepOpaqueIdOnly(@event.User);
return @event;
}
public static SentryTransaction Scrub(SentryTransaction transaction)
{
ScrubRequest(transaction.Request);
transaction.User = KeepOpaqueIdOnly(transaction.User);
transaction.SetTag("trace_id", transaction.TraceId.ToString());
transaction.SetTag("transaction_id", transaction.SpanId.ToString());
ScrubDictionary(transaction.Data, SafeSpanDataKeys);
foreach (var span in transaction.Spans)
{
ScrubDictionary(span.Data, SafeSpanDataKeys);
foreach (var tag in span.Tags.Keys.Where(key => !SafeSpanTagKeys.Contains(key)).ToArray())
span.UnsetTag(tag);
span.Description = span.Operation?.Contains("http", StringComparison.OrdinalIgnoreCase) == true
? NormalizeHttpDescription(span.Description)
: null;
}
return transaction;
}
private static SentryUser KeepOpaqueIdOnly(SentryUser? user) =>
string.IsNullOrWhiteSpace(user?.Id) ? new SentryUser() : new SentryUser { Id = user.Id };
private static void ScrubRequest(SentryRequest? request)
{
if (request is null)
return;
request.Data = null;
request.QueryString = null;
request.Cookies = null;
request.Headers.Clear();
request.Env.Clear();
request.Other.Clear();
}
private static void ScrubDictionary<TValue>(
IReadOnlyDictionary<string, TValue> values,
IReadOnlySet<string> allowedKeys)
{
if (values is not IDictionary<string, TValue> mutable)
return;
foreach (var key in mutable.Keys.Where(key => !allowedKeys.Contains(key)).ToArray())
mutable.Remove(key);
}
private static string? NormalizeHttpDescription(string? description)
{
if (string.IsNullOrWhiteSpace(description))
return null;
var separator = description.IndexOf(' ');
if (separator <= 0 || separator == description.Length - 1)
return null;
var method = description[..separator].ToUpperInvariant();
if (method is not ("GET" or "POST" or "PUT" or "PATCH" or "DELETE" or "HEAD" or "OPTIONS"))
return null;
var rawUrl = description[(separator + 1)..];
var path = Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute)
? absolute.GetLeftPart(UriPartial.Authority) + absolute.AbsolutePath
: rawUrl.Split('?', '#')[0];
return $"{method} {NormalizePathIdentifiers(path)}";
}
private static string NormalizePathIdentifiers(string path) =>
string.Join('/', path.Split('/').Select(segment => IsIdentifierSegment(segment) ? ":id" : segment));
private static bool IsIdentifierSegment(string segment)
{
if (string.IsNullOrEmpty(segment))
return false;
if (long.TryParse(segment, out _) || Guid.TryParse(segment, out _))
return true;
var allHex = segment.All(Uri.IsHexDigit);
if (segment.Length >= 8 && allHex)
return true;
return segment.Length >= 16
&& segment.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '~' or '-')
&& segment.Any(char.IsAsciiLetter)
&& segment.Any(char.IsAsciiDigit);
}
}
public sealed class SentryBackgroundTransactionHandle : IDisposable
{
private readonly ITransactionTracer _transaction;
private readonly IDisposable _scope;
private int _finished;
internal SentryBackgroundTransactionHandle(ITransactionTracer transaction, IDisposable scope)
{
_transaction = transaction;
_scope = scope;
}
public void FinishOk() => Finish(() => _transaction.Finish(SpanStatus.Ok));
public void FinishCancelled() => Finish(() => _transaction.Finish(SpanStatus.Cancelled));
public void FinishError(Exception exception) =>
Finish(() => _transaction.Finish(exception, SpanStatus.InternalError));
private void Finish(Action finish)
{
if (Interlocked.Exchange(ref _finished, 1) == 0)
finish();
}
public void Dispose()
{
Finish(() => _transaction.Finish(SpanStatus.UnknownError));
_scope.Dispose();
}
}