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()? .InformationalVersion; return ResolveRelease(informationalVersion); } public static string? ResolveCommitSha(Assembly? assembly) { var informationalVersion = assembly? .GetCustomAttribute()? .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(); 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 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 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( IReadOnlyDictionary values, IReadOnlySet allowedKeys) { if (values is not IDictionary 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(); } }