File: Internal\HostingTelemetryHelpers.cs
Web Access
Project: ..\..\..\src\aspnetcore\src\Hosting\Hosting\src\Microsoft.AspNetCore.Hosting.csproj (Microsoft.AspNetCore.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Frozen;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Microsoft.AspNetCore.Http;
 
namespace Microsoft.AspNetCore.Hosting;
 
/// <summary>
/// Follows OpenTelemetry Semantic Conventions for HTTP
/// <a href="https://github.com/open-telemetry/semantic-conventions/blob/v1.44.0/docs/http/http-spans.md">v1.44.0</a>
/// </summary>
internal static class HostingTelemetryHelpers
{
    public const string AttributeHttpRequestMethod = "http.request.method";
    public const string AttributeHttpRequestMethodOriginal = "http.request.method_original";
    public const string AttributeHttpResponseStatusCode = "http.response.status_code";
    public const string AttributeHttpRoute = "http.route";
    public const string AttributeUrlScheme = "url.scheme";
    public const string AttributeUrlPath = "url.path";
    public const string AttributeUrlQuery = "url.query";
    public const string AttributeClientAddress = "client.address";
    public const string AttributeServerAddress = "server.address";
    public const string AttributeServerPort = "server.port";
    public const string AttributeNetworkPeerAddress = "network.peer.address";
    public const string AttributeNetworkPeerPort = "network.peer.port";
    public const string AttributeUserAgentOriginal = "user_agent.original";
    public const string AttributeNetworkProtocolVersion = "network.protocol.version";
    public const string AttributeErrorType = "error.type";
 
    // The value "_OTHER" is used for non-standard HTTP methods.
    private const string OtherHttpMethod = "_OTHER";
    private const string KnownHttpMethodsEnvironmentVariable = "OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS";
    private const string RedactedQueryParameterValue = "REDACTED";
 
    private static readonly object[] BoxedStatusCodes = new object[512];
 
    private static readonly FrozenDictionary<string, string> KnownHttpMethods =
        CreateKnownHttpMethods(Environment.GetEnvironmentVariable(KnownHttpMethodsEnvironmentVariable));
 
    // Boxed port values for HTTP and HTTPS.
    private static readonly object HttpPort = 80;
    private static readonly object HttpsPort = 443;
 
    public static bool TryGetServerPort(HostString host, string scheme, [NotNullWhen(true)] out object? port)
    {
        if (host.Port.HasValue)
        {
            port = host.Port.Value;
            return true;
        }
 
        // If the port is not specified, use the default port for the scheme.
        if (string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase))
        {
            port = HttpPort;
            return true;
        }
        else if (string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase))
        {
            port = HttpsPort;
            return true;
        }
 
        // Unknown scheme, no default port.
        port = null;
        return false;
    }
 
    public static object GetBoxedStatusCode(int statusCode)
    {
        var boxes = BoxedStatusCodes;
        return (uint)statusCode < (uint)boxes.Length
            ? boxes[statusCode] ??= statusCode
            : statusCode;
    }
 
    public static string GetNormalizedHttpMethod(string method)
    {
        if (method is not null && KnownHttpMethods.TryGetValue(method, out var result))
        {
            // KnownHttpMethods ignores case. Use the value returned by the dictionary to have a consistent case.
            return result;
        }
        return OtherHttpMethod;
    }
 
    // Internal for testing.
    internal static FrozenDictionary<string, string> CreateKnownHttpMethods(string? configuredKnownMethods)
    {
        if (!string.IsNullOrEmpty(configuredKnownMethods))
        {
            var knownHttpMethods = new Dictionary<string, string>(StringComparer.Ordinal);
 
            foreach (var method in configuredKnownMethods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
            {
                knownHttpMethods[method] = method;
            }
 
            if (knownHttpMethods.Count > 0)
            {
                return knownHttpMethods.ToFrozenDictionary(StringComparer.Ordinal);
            }
        }
 
        return FrozenDictionary.ToFrozenDictionary([
            KeyValuePair.Create(HttpMethods.Connect, HttpMethods.Connect),
            KeyValuePair.Create(HttpMethods.Delete, HttpMethods.Delete),
            KeyValuePair.Create(HttpMethods.Get, HttpMethods.Get),
            KeyValuePair.Create(HttpMethods.Head, HttpMethods.Head),
            KeyValuePair.Create(HttpMethods.Options, HttpMethods.Options),
            KeyValuePair.Create(HttpMethods.Patch, HttpMethods.Patch),
            KeyValuePair.Create(HttpMethods.Post, HttpMethods.Post),
            KeyValuePair.Create(HttpMethods.Put, HttpMethods.Put),
            KeyValuePair.Create(HttpMethods.Query, HttpMethods.Query),
            KeyValuePair.Create(HttpMethods.Trace, HttpMethods.Trace)
        ], StringComparer.OrdinalIgnoreCase);
    }
 
    public static string GetRedactedQueryString(string queryString)
    {
        Debug.Assert(queryString.Length > 0 && queryString[0] == '?');
 
        var query = queryString.AsSpan(1);
        StringBuilder? builder = null;
        var copyFrom = 0;
        var segmentStart = 0;
 
        while (segmentStart < query.Length)
        {
            var segmentLength = query[segmentStart..].IndexOf('&');
            if (segmentLength < 0)
            {
                segmentLength = query.Length - segmentStart;
            }
 
            var segment = query.Slice(segmentStart, segmentLength);
            var equalsIndex = segment.IndexOf('=');
 
            if (equalsIndex >= 0 && IsSensitiveQueryParameter(segment[..equalsIndex]))
            {
                builder ??= new StringBuilder(query.Length);
                var valueStart = segmentStart + equalsIndex + 1;
                builder.Append(query[copyFrom..valueStart]);
                builder.Append(RedactedQueryParameterValue);
                copyFrom = segmentStart + segmentLength;
            }
 
            segmentStart += segmentLength + 1;
        }
 
        if (builder is null)
        {
            return queryString[1..];
        }
 
        builder.Append(query[copyFrom..]);
        return builder.ToString();
    }
 
    private static bool IsSensitiveQueryParameter(ReadOnlySpan<char> name)
    {
        if (IsSensitiveQueryParameterName(name))
        {
            return true;
        }
 
        if (!name.ContainsAny('%', '+'))
        {
            return false;
        }
 
        try
        {
            var decodedName = Uri.UnescapeDataString(name.ToString().Replace('+', ' '));
            return IsSensitiveQueryParameterName(decodedName);
        }
        catch (Exception ex) when (ex is UriFormatException or ArgumentException)
        {
            // Invalid escape sequence; treat as non-sensitive to preserve the original query text.
            return false;
        }
    }
 
    // search `url.query` at OpenTelemetry Semantic Conventions doc to see which query string keys should have redacted values.
    private static bool IsSensitiveQueryParameterName(ReadOnlySpan<char> name) =>
        name is
            "X-Amz-Signature" or
            "X-Amz-Credential" or
            "X-Amz-Security-Token" or
            "sig" or
            "X-Goog-Signature" or
            "access_token" // included because SignalR browser transports use it for bearer tokens.
            ;
 
    public static bool TryGetHttpVersion(string protocol, [NotNullWhen(true)] out string? version)
    {
        if (HttpProtocol.IsHttp11(protocol))
        {
            version = "1.1";
            return true;
        }
        if (HttpProtocol.IsHttp2(protocol))
        {
            // HTTP/2 only has one version.
            version = "2";
            return true;
        }
        if (HttpProtocol.IsHttp3(protocol))
        {
            // HTTP/3 only has one version.
            version = "3";
            return true;
        }
        if (HttpProtocol.IsHttp10(protocol))
        {
            version = "1.0";
            return true;
        }
        if (HttpProtocol.IsHttp09(protocol))
        {
            version = "0.9";
            return true;
        }
        version = null;
        return false;
    }
 
    public static void SetActivityHttpMethodTags(ref TagList tags, string originalHttpMethod)
    {
        var normalizedHttpMethod = GetNormalizedHttpMethod(originalHttpMethod);
        tags.Add(AttributeHttpRequestMethod, normalizedHttpMethod);
 
        if (originalHttpMethod != normalizedHttpMethod)
        {
            tags.Add(AttributeHttpRequestMethodOriginal, originalHttpMethod);
        }
    }
 
    /// <summary>
    /// Determines if the status code indicates a server error (5xx).
    /// Client errors (4xx) are not considered server errors.
    /// </summary>
    public static bool IsErrorStatusCode(int statusCode) => statusCode >= 500 && statusCode <= 599;
 
    public static string GetActivityDisplayName(string originalHttpMethod, string? httpRoute = null)
    {
        var normalizedHttpMethod = GetNormalizedHttpMethod(originalHttpMethod);
        var namePrefix = normalizedHttpMethod == OtherHttpMethod ? "HTTP" : normalizedHttpMethod;
 
        return string.IsNullOrEmpty(httpRoute) ? namePrefix : $"{namePrefix} {httpRoute}";
    }
}