File: System\Net\Http\SocketsHttpHandler\Metrics\SocketsHttpHandlerMetrics.cs
Web Access
Project: src\runtime\src\libraries\System.Net.Http\src\System.Net.Http.csproj (System.Net.Http)
// 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.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace System.Net.Http.Metrics
{
    /// <summary>
    /// Represents a unique combination of tags for tracking open connections.
    /// </summary>
    internal readonly struct OpenConnectionsTagKey : IEquatable<OpenConnectionsTagKey>
    {
        public readonly string ProtocolVersion;
        public readonly string Scheme;
        public readonly string Host;
        public readonly int Port;
        public readonly bool IsIdle;
        public readonly string? PeerAddress;
        private readonly int _hashCode;

        public OpenConnectionsTagKey(string protocolVersion, string scheme, string host, int port, bool isIdle, string? peerAddress)
        {
            ProtocolVersion = protocolVersion;
            Scheme = scheme;
            Host = host;
            Port = port;
            IsIdle = isIdle;
            PeerAddress = peerAddress;
            _hashCode = HashCode.Combine(protocolVersion, scheme, host, port, isIdle, peerAddress);
        }

        public bool Equals(OpenConnectionsTagKey other) =>
            ProtocolVersion == other.ProtocolVersion &&
            Scheme == other.Scheme &&
            Host == other.Host &&
            Port == other.Port &&
            IsIdle == other.IsIdle &&
            PeerAddress == other.PeerAddress;

        public override bool Equals(object? obj) => obj is OpenConnectionsTagKey other && Equals(other);

        public override int GetHashCode() => _hashCode;

        public TagList ToTagList()
        {
            TagList tags = default;
            tags.Add("network.protocol.version", ProtocolVersion);
            tags.Add("url.scheme", Scheme);
            tags.Add("server.address", Host);
            tags.Add("server.port", DiagnosticsHelper.GetBoxedInt32(Port));
            tags.Add("http.connection.state", IsIdle ? "idle" : "active");
            if (PeerAddress is not null)
            {
                tags.Add("network.peer.address", PeerAddress);
            }
            return tags;
        }

        public override string ToString() =>
            $"HTTP/{ProtocolVersion} {Scheme}://{Host}:{Port} {(IsIdle ? "idle" : "active")}{(PeerAddress is not null ? $" {PeerAddress}" : "")}";
    }

    /// <summary>
    /// Thread-safe tracker for open connection counts by tag combination.
    /// </summary>
    internal sealed class OpenConnectionsTracker
    {
        private readonly ConcurrentDictionary<OpenConnectionsTagKey, long> _counts = new();

        /// <summary>
        /// Increments the count for the specified tag combination.
        /// </summary>
        public void Increment(in OpenConnectionsTagKey key)
        {
            _counts.AddOrUpdate(key, 1, static (_, currentValue) => currentValue + 1);
        }

        /// <summary>
        /// Decrements the count for the specified tag combination.
        /// Removes the entry if the count reaches zero.
        /// </summary>
        public void Decrement(in OpenConnectionsTagKey key)
        {
            // We need to atomically decrement and remove if zero.
            // Use a spin loop with TryGetValue/TryUpdate/TryRemove to handle this safely.
            while (true)
            {
                if (!_counts.TryGetValue(key, out long currentValue))
                {
                    // Key doesn't exist, nothing to decrement.
                    // This shouldn't happen in normal operation but we handle it gracefully.
                    Debug.Fail($"Decrement for non-existing connection {key}");
                    return;
                }

                if (currentValue <= 1)
                {
                    // Try to remove the entry since it will become zero.
                    // Use the overload that checks the current value to ensure atomicity.
                    if (_counts.TryRemove(new KeyValuePair<OpenConnectionsTagKey, long>(key, currentValue)))
                    {
                        return;
                    }
                    // Another thread modified the value, retry.
                }
                else
                {
                    // Try to decrement the value.
                    if (_counts.TryUpdate(key, currentValue - 1, currentValue))
                    {
                        return;
                    }
                    // Another thread modified the value, retry.
                }
            }
        }

        /// <summary>
        /// Returns measurements for all tag combinations with non-zero counts.
        /// </summary>
        public IEnumerable<Measurement<long>> GetMeasurements()
        {
            foreach (KeyValuePair<OpenConnectionsTagKey, long> entry in _counts)
            {
                yield return new Measurement<long>(entry.Value, entry.Key.ToTagList());
            }
        }
    }

    internal sealed class SocketsHttpHandlerMetrics
    {
        public readonly OpenConnectionsTracker OpenConnectionsTracker = new();

        public readonly ObservableUpDownCounter<long> OpenConnections;

        public readonly Histogram<double> ConnectionDuration;

        public readonly Histogram<double> RequestsQueueDuration;

        public SocketsHttpHandlerMetrics(Meter meter)
        {
            OpenConnections = meter.CreateObservableUpDownCounter<long>(
                name: "http.client.open_connections",
                observeValues: OpenConnectionsTracker.GetMeasurements,
                unit: "{connection}",
                description: "Number of outbound HTTP connections that are currently active or idle on the client.");

            ConnectionDuration = meter.CreateHistogram<double>(
                name: "http.client.connection.duration",
                unit: "s",
                description: "The duration of successfully established outbound HTTP connections.",
                advice: new InstrumentAdvice<double>()
                {
                    // These values are not based on a standard and may change in the future.
                    HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]
                });

            RequestsQueueDuration = meter.CreateHistogram<double>(
                name: "http.client.request.time_in_queue",
                unit: "s",
                description: "The amount of time requests spent on a queue waiting for an available connection.",
                advice: DiagnosticsHelper.ShortHistogramAdvice);
        }

        public void RequestLeftQueue(HttpRequestMessage request, HttpConnectionPool pool, TimeSpan duration, int versionMajor)
        {
            Debug.Assert(versionMajor is 1 or 2 or 3);

            if (RequestsQueueDuration.Enabled)
            {
                TagList tags = default;

                // While requests may report HTTP/1.0 as the protocol, we treat all HTTP/1.X connections as HTTP/1.1.
                tags.Add("network.protocol.version", versionMajor switch
                {
                    1 => "1.1",
                    2 => "2",
                    _ => "3"
                });

                tags.Add("url.scheme", pool.IsSecure ? "https" : "http");

                Debug.Assert(pool.TelemetryServerAddress is not null, "TelemetryServerAddress should not be null when System.Diagnostics.Metrics.Meter.IsSupported is true.");
                tags.Add("server.address", pool.TelemetryServerAddress);

                if (!pool.IsDefaultPort)
                {
                    tags.Add("server.port", pool.OriginAuthority.Port);
                }

                tags.Add(DiagnosticsHelper.GetMethodTag(request.Method, out _));

                RequestsQueueDuration.Record(duration.TotalSeconds, tags);
            }
        }
    }
}