mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
297 lines
9.8 KiB
C#
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();
|
|
}
|
|
}
|