Skip to content

[API Proposal]: Public abstract HttpDependencyMetadataResolver for custom request classification strategies #6840

@rainsxng

Description

@rainsxng

Background and motivation

HTTP requests to downstream services often require consistent classification and metadata handling for telemetry, routing, and policy application. The HttpDependencyMetadataResolver provides a highly optimized solution using trie-based lookups to efficiently map HTTP requests to their corresponding metadata.

By exposing this as a public abstract class, we allow applications to:

  1. Leverage the core functionality without re-implementing complex matching logic
  2. Extend or customize the implementation for specific environments
  3. Integrate with existing service discovery or configuration systems

An abstract class is chosen mainly because we must support .NET Framework, where default interface implementations are unavailable. An interface alone would force every consumer who needs even a minor extension (alternate host resolution, custom path normalization, dynamic metadata refresh) to re‑implement the entire resolution pipeline, duplicating the high‑performance trie logic and risking divergence. The abstract base lets us lock down and reuse the optimized core (host suffix trie, route/method matching, wildcard handling) while exposing just enough surface for advanced scenarios that need controlled overrides, and it remains mockable for tests without leaking internals. This gives safe extensibility under old target frameworks and eliminates boilerplate that a pure interface would impose there.

API Proposal

namespace Microsoft.Extensions.Http.Diagnostics;

/// <summary>
/// Resolves metadata for HTTP requests based on hostname, path, and method patterns.
/// </summary>
/// <remarks>
/// This class provides a high-performance way to identify HTTP requests by mapping them to previously
/// configured metadata using specialized trie-based data structures. This enables efficient lookup
/// of service information, operation names, and other metadata for telemetry and policy application.
/// </remarks>
public abstract class HttpDependencyMetadataResolver
{
 if NET462    
    /// <summary>
    /// Gets request metadata for the specified HTTP web request.
    /// </summary>
    /// <param name="requestMessage">The HTTP web request.</param>
    /// <returns>The resolved <see cref="RequestMetadata"/> if found; otherwise, <see langword="null"/>.</returns>
    public virtual RequestMetadata? GetRequestMetadata(HttpWebRequest requestMessage);
#else
    /// <summary>
    /// Gets request metadata for the specified HTTP request message.
    /// </summary>
    /// <param name="requestMessage">The HTTP request message.</param>
    /// <returns>The resolved <see cref="RequestMetadata"/> if found; otherwise, <see langword="null"/>.</returns>
    public virtual RequestMetadata? GetRequestMetadata(HttpRequestMessage requestMessage);
#endif
}

HttpDependencyMetadataResolver conditionally exposes a single GetRequestMetadata overload per target framework: on modern targets it provides the HttpRequestMessage version (for the recommended HttpClient / IHttpClientFactory pipeline), and on .NET Framework (net462) it instead provides the HttpWebRequest version to preserve legacy compatibility. Conditional compilation ensures only the relevant API is surfaced, while the internal resolution logic (method, host, path -> metadata) remains identical across target frameworks. This avoids duplicate surface area, keeps call sites clean, and still delivers consistent classification behavior on every supported runtime.

The API proposal includes an extension method to register the HttpDependencyMetadataResolver and its default implementation in the service collection:

namespace Microsoft.Extensions.DependencyInjection

/// <summary>
/// Extensions for telemetry utilities.
/// </summary>
public static class HttpDiagnosticsServiceCollectionExtensions
{
    /// <summary>
    /// Adds services required for HTTP dependency metadata resolution.
    /// </summary>
    /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
    /// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
    public static IServiceCollection AddStandardHttpDependencyMetadataResolver(this IServiceCollection services);
}

API Usage

The example below demonstrates a comprehensive HTTP request processing flow that showcases how the proposed abstract class enables flexible dependency resolution. The flow illustrates how the HttpDependencyMetadataResolver abstract class would serve as a core building block in an HTTP processing pipeline.

Register extension methods

// Registers only the resolver (no providers). Must add providers separately before first use.
builder.Services.AddDownstreamDependencyMetadata<PaymentApiMetadata>()
                .AddDownstreamDependencyMetadata<StorageApiMetadata>()
                .AddStandardHttpDependencyMetadataResolver();

Example metadata providers (minimal):

internal sealed class PaymentApiMetadata : IDownstreamDependencyMetadata
{
    public string DependencyName => "PaymentsService";
    public ISet<string> UniqueHostNameSuffixes { get; } =
        new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "payments.example.com" };
    public ISet<RequestMetadata> RequestMetadata { get; } = new HashSet<RequestMetadata>
    {
        new("GET",  "/v1/orders/{orderId}", "GetOrder"),
        new("POST", "/v1/orders",           "CreateOrder")
    };
}

internal sealed class StorageApiMetadata : IDownstreamDependencyMetadata
{
    public string DependencyName => "StorageService";
    public ISet<string> UniqueHostNameSuffixes { get; } =
        new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "storage.internal.local" };
    public ISet<RequestMetadata> RequestMetadata { get; } = new HashSet<RequestMetadata>
    {
        new("GET", "/blobs/{container}/{blob}", "GetBlob")
    };
}

Metrics publishing delegating handler using the default resolver:

public sealed class DependencyMetricsHandler : DelegatingHandler
{
    private static readonly Meter Meter = new("MyCompany.Http.Outgoing");
    private static readonly Histogram<double> DurationMs =
        Meter.CreateHistogram<double>("http.outgoing.duration.ms", unit: "ms");
    private static readonly Counter<long> RequestCount =
        Meter.CreateCounter<long>("http.outgoing.requests");

    private readonly HttpDependencyMetadataResolver _resolver;

    private static readonly HttpRequestOptionsKey<RequestMetadata> MetaKey =
        new("DependencyMetadata");
    private static readonly HttpRequestOptionsKey<long> StartTicksKey =
        new("DependencyStartTicks");

    public DependencyMetricsHandler(HttpDependencyMetadataResolver resolver) => _resolver = resolver;

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        var meta = _resolver.GetRequestMetadata(request);
        if (meta != null)
        {
            request.Options.Set(MetaKey, meta);
        }

        var start = Stopwatch.GetTimestamp();
        request.Options.Set(StartTicksKey, start);

        HttpResponseMessage response = null!;
        Exception error = null;
        try
        {
            response = await base.SendAsync(request, ct).ConfigureAwait(false);
            return response;
        }
        catch (Exception ex)
        {
            error = ex;
            throw;
        }
        finally
        {
            PublishMetrics(request, response, error, start, meta);
        }
    }

    private static void PublishMetrics(
        HttpRequestMessage request,
        HttpResponseMessage? response,
        Exception? error,
        long startTicks,
        RequestMetadata? meta)
    {
        var elapsedMs = Stopwatch.GetElapsedTime(startTicks).TotalMilliseconds;

        var dependency = meta?.DependencyName ?? "unknown";
        var operation = meta?.RequestName != null && meta.RequestName != TelemetryConstants.Unknown
            ? meta.RequestName
            : (meta?.RequestRoute ?? "unknown");
        var statusCode = response?.StatusCode != null
            ? ((int)response.StatusCode).ToString()
            : (error is null ? "0" : "ERR");
        var outcome = error is null
            ? (response != null && response.IsSuccessStatusCode ? "success" : "failure")
            : "exception";

        DurationMs.Record(
            elapsedMs,
            new KeyValuePair<string, object?>("dependency", dependency),
            new KeyValuePair<string, object?>("operation", operation),
            new KeyValuePair<string, object?>("status_code", statusCode),
            new KeyValuePair<string, object?>("outcome", outcome),
            new KeyValuePair<string, object?>("method", request.Method.Method));

        RequestCount.Add(
            1,
            new KeyValuePair<string, object?>("dependency", dependency),
            new KeyValuePair<string, object?>("operation", operation),
            new KeyValuePair<string, object?>("outcome", outcome));
    }
}

Additional example that uses resolver directly in the application code:

public sealed class PaymentClient
{
    private readonly HttpClient _http;
    private readonly HttpDependencyMetadataResolver _resolver;

    public PaymentClient(HttpClient http, HttpDependencyMetadataResolver resolver)
    {
        _http = http;
        _resolver = resolver;
    }

    public async Task<HttpResponseMessage> GetOrderAsync(string id, CancellationToken ct = default)
    {
        var req = new HttpRequestMessage(HttpMethod.Get, $"https://example.com/v1/orders/{id}");
        var meta = _resolver.GetRequestMetadata(req); // enrich logs if desired
        return await _http.SendAsync(req, ct).ConfigureAwait(false);
    }
}

Alternative Designs

While the abstract class design for HttpDependencyMetadataResolver offers significant benefits through shared implementation logic, an interface-based approach could provide greater flexibility in certain scenarios.

public interface IHttpDependencyMetadataResolver
{
 if NET462    
    /// <summary>
    /// Gets request metadata for the specified HTTP web request.
    /// </summary>
    /// <param name="requestMessage">The HTTP web request.</param>
    /// <returns>The resolved <see cref="RequestMetadata"/> if found; otherwise, <see langword="null"/>.</returns>
    public virtual RequestMetadata? GetRequestMetadata(HttpWebRequest requestMessage);
#else
    /// <summary>
    /// Gets request metadata for the specified HTTP request message.
    /// </summary>
    /// <param name="requestMessage">The HTTP request message.</param>
    /// <returns>The resolved <see cref="RequestMetadata"/> if found; otherwise, <see langword="null"/>.</returns>
    public virtual RequestMetadata? GetRequestMetadata(HttpRequestMessage requestMessage);
#endif
}

We could have exposed just an interface here, but because we must support .NET Framework (no default interface implementations) every implementation would then have to re‑implement or copy the optimized host/route/method trie logic. Using an abstract class lets us keep a single, high‑performance implementation while still allowing controlled extension points, avoiding duplicate code and performance drift across frameworks.

Risks

No response

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-telemetry

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions