diff --git a/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj b/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj
index 4a53a07a61b4..9b5d4268cb21 100644
--- a/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj
+++ b/src/Hosting/Abstractions/src/Microsoft.AspNetCore.Hosting.Abstractions.csproj
@@ -11,28 +11,6 @@
     <IsTrimmable>true</IsTrimmable>
   </PropertyGroup>
 
-  <ItemGroup>
-    <Compile Include="$(SharedSourceRoot)Metrics\**\*.cs" LinkBase="Metrics" />
-  </ItemGroup>
-
-  <!-- Temporary hack to make prototype Metrics DI integration types available -->
-  <!-- TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 -->
-  <ItemGroup>
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Hosting" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Hosting.Tests" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Core" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Core.Tests" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Tests" />
-    <InternalsVisibleTo Include="InMemory.FunctionalTests" />
-    <InternalsVisibleTo Include="Sockets.BindTests" />
-    <InternalsVisibleTo Include="Sockets.FunctionalTests" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Microbenchmarks" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Connections" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Connections.Tests" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.SignalR" />
-    <InternalsVisibleTo Include="Microsoft.AspNetCore.Diagnostics.Tests" />
-  </ItemGroup>
-
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
diff --git a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
index ff0c54dadae1..ccdbcde598a7 100644
--- a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
+++ b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj
@@ -53,6 +53,29 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
     </Compile>
   </ItemGroup>
 
+  <!-- Temporary hack to make prototype Metrics DI integration types available -->
+  <!-- TODO: Remove when Metrics DI intergration package is available https://github.com/dotnet/aspnetcore/issues/47618 -->
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)Metrics\**\*.cs" LinkBase="Metrics" />
+  </ItemGroup>
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Hosting" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Hosting.Tests" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Core" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Core.Tests" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Tests" />
+    <InternalsVisibleTo Include="InMemory.FunctionalTests" />
+    <InternalsVisibleTo Include="Sockets.BindTests" />
+    <InternalsVisibleTo Include="Sockets.FunctionalTests" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Microbenchmarks" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Connections" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Connections.Tests" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.SignalR" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Diagnostics.Tests" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.RateLimiting" />
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.RateLimiting.Tests" />
+  </ItemGroup>
+
   <ItemGroup>
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Http.Abstractions.Tests" />
   </ItemGroup>
diff --git a/src/Middleware/RateLimiting/src/LeaseContext.cs b/src/Middleware/RateLimiting/src/LeaseContext.cs
index 1b78c3f8bf85..98068291bf34 100644
--- a/src/Middleware/RateLimiting/src/LeaseContext.cs
+++ b/src/Middleware/RateLimiting/src/LeaseContext.cs
@@ -22,4 +22,4 @@ internal enum RequestRejectionReason
     EndpointLimiter,
     GlobalLimiter,
     RequestCanceled
-}
\ No newline at end of file
+}
diff --git a/src/Middleware/RateLimiting/src/MetricsContext.cs b/src/Middleware/RateLimiting/src/MetricsContext.cs
new file mode 100644
index 000000000000..36995e5a53a2
--- /dev/null
+++ b/src/Middleware/RateLimiting/src/MetricsContext.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.RateLimiting;
+
+internal readonly struct MetricsContext
+{
+    public readonly string? PolicyName;
+    public readonly string? Method;
+    public readonly string? Route;
+    public readonly bool CurrentLeaseRequestsCounterEnabled;
+    public readonly bool CurrentRequestsQueuedCounterEnabled;
+
+    public MetricsContext(string? policyName, string? method, string? route, bool currentLeaseRequestsCounterEnabled, bool currentRequestsQueuedCounterEnabled)
+    {
+        PolicyName = policyName;
+        Method = method;
+        Route = route;
+        CurrentLeaseRequestsCounterEnabled = currentLeaseRequestsCounterEnabled;
+        CurrentRequestsQueuedCounterEnabled = currentRequestsQueuedCounterEnabled;
+    }
+}
diff --git a/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs b/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs
index cd1ec7b82604..360c3baafb3a 100644
--- a/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs
+++ b/src/Middleware/RateLimiting/src/RateLimiterApplicationBuilderExtensions.cs
@@ -2,7 +2,9 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
+using Resources = Microsoft.AspNetCore.RateLimiting.Resources;
 
 namespace Microsoft.AspNetCore.Builder;
 
@@ -20,6 +22,8 @@ public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app)
     {
         ArgumentNullException.ThrowIfNull(app);
 
+        VerifyServicesAreRegistered(app);
+
         return app.UseMiddleware<RateLimitingMiddleware>();
     }
 
@@ -34,6 +38,19 @@ public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app, R
         ArgumentNullException.ThrowIfNull(app);
         ArgumentNullException.ThrowIfNull(options);
 
+        VerifyServicesAreRegistered(app);
+
         return app.UseMiddleware<RateLimitingMiddleware>(Options.Create(options));
     }
+
+    private static void VerifyServicesAreRegistered(IApplicationBuilder app)
+    {
+        var serviceProviderIsService = app.ApplicationServices.GetService<IServiceProviderIsService>();
+        if (serviceProviderIsService != null && !serviceProviderIsService.IsService(typeof(RateLimitingMetrics)))
+        {
+            throw new InvalidOperationException(Resources.FormatUnableToFindServices(
+                nameof(IServiceCollection),
+                nameof(RateLimiterServiceCollectionExtensions.AddRateLimiter)));
+        }
+    }
 }
diff --git a/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs b/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs
index ac3c6718000f..09f6f7ba7c5c 100644
--- a/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs
+++ b/src/Middleware/RateLimiting/src/RateLimiterServiceCollectionExtensions.cs
@@ -22,6 +22,8 @@ public static IServiceCollection AddRateLimiter(this IServiceCollection services
         ArgumentNullException.ThrowIfNull(services);
         ArgumentNullException.ThrowIfNull(configureOptions);
 
+        services.AddMetrics();
+        services.AddSingleton<RateLimitingMetrics>();
         services.Configure(configureOptions);
         return services;
     }
diff --git a/src/Middleware/RateLimiting/src/RateLimitingMetrics.cs b/src/Middleware/RateLimiting/src/RateLimitingMetrics.cs
new file mode 100644
index 000000000000..cf7bbb533e7a
--- /dev/null
+++ b/src/Middleware/RateLimiting/src/RateLimitingMetrics.cs
@@ -0,0 +1,177 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.Metrics;
+
+namespace Microsoft.AspNetCore.RateLimiting;
+
+internal sealed class RateLimitingMetrics : IDisposable
+{
+    public const string MeterName = "Microsoft.AspNetCore.RateLimiting";
+
+    private readonly Meter _meter;
+    private readonly UpDownCounter<long> _currentLeasedRequestsCounter;
+    private readonly Histogram<double> _leasedRequestDurationCounter;
+    private readonly UpDownCounter<long> _currentQueuedRequestsCounter;
+    private readonly Histogram<double> _queuedRequestDurationCounter;
+    private readonly Counter<long> _leaseFailedRequestsCounter;
+
+    public RateLimitingMetrics(IMeterFactory meterFactory)
+    {
+        _meter = meterFactory.CreateMeter(MeterName);
+
+        _currentLeasedRequestsCounter = _meter.CreateUpDownCounter<long>(
+            "current-leased-requests",
+            description: "Number of HTTP requests that are currently active on the server that hold a rate limiting lease.");
+
+        _leasedRequestDurationCounter = _meter.CreateHistogram<double>(
+            "leased-request-duration",
+            unit: "s",
+            description: "The duration of rate limiting leases held by HTTP requests on the server.");
+
+        _currentQueuedRequestsCounter = _meter.CreateUpDownCounter<long>(
+            "current-queued-requests",
+            description: "Number of HTTP requests that are currently queued, waiting to acquire a rate limiting lease.");
+
+        _queuedRequestDurationCounter = _meter.CreateHistogram<double>(
+            "queued-request-duration",
+            unit: "s",
+            description: "The duration of HTTP requests in a queue, waiting to acquire a rate limiting lease.");
+
+        _leaseFailedRequestsCounter = _meter.CreateCounter<long>(
+            "lease-failed-requests",
+            description: "Number of HTTP requests that failed to acquire a rate limiting lease. Requests could be rejected by global or endpoint rate limiting policies. Or the request could be canceled while waiting for the lease.");
+    }
+
+    public bool CurrentLeasedRequestsCounterEnabled => _currentLeasedRequestsCounter.Enabled;
+    public bool CurrentQueuedRequestsCounterEnabled => _currentQueuedRequestsCounter.Enabled;
+
+    public void LeaseFailed(in MetricsContext metricsContext, RequestRejectionReason reason)
+    {
+        if (_leaseFailedRequestsCounter.Enabled)
+        {
+            LeaseFailedCore(metricsContext, reason);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void LeaseFailedCore(in MetricsContext metricsContext, RequestRejectionReason reason)
+    {
+        var tags = new TagList();
+        InitializeRateLimitingTags(ref tags, metricsContext);
+        tags.Add("reason", reason.ToString());
+        _leaseFailedRequestsCounter.Add(1, tags);
+    }
+
+    public void LeaseStart(in MetricsContext metricsContext)
+    {
+        if (metricsContext.CurrentLeaseRequestsCounterEnabled)
+        {
+            LeaseStartCore(metricsContext);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    public void LeaseStartCore(in MetricsContext metricsContext)
+    {
+        var tags = new TagList();
+        InitializeRateLimitingTags(ref tags, metricsContext);
+        _currentLeasedRequestsCounter.Add(1, tags);
+    }
+
+    public void LeaseEnd(in MetricsContext metricsContext, long startTimestamp, long currentTimestamp)
+    {
+        if (metricsContext.CurrentLeaseRequestsCounterEnabled || _leasedRequestDurationCounter.Enabled)
+        {
+            LeaseEndCore(metricsContext, startTimestamp, currentTimestamp);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void LeaseEndCore(in MetricsContext metricsContext, long startTimestamp, long currentTimestamp)
+    {
+        var tags = new TagList();
+        InitializeRateLimitingTags(ref tags, metricsContext);
+
+        if (metricsContext.CurrentLeaseRequestsCounterEnabled)
+        {
+            _currentLeasedRequestsCounter.Add(-1, tags);
+        }
+
+        if (_leasedRequestDurationCounter.Enabled)
+        {
+            var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp);
+            _leasedRequestDurationCounter.Record(duration.TotalSeconds, tags);
+        }
+    }
+
+    public void QueueStart(in MetricsContext metricsContext)
+    {
+        if (metricsContext.CurrentRequestsQueuedCounterEnabled)
+        {
+            QueueStartCore(metricsContext);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void QueueStartCore(in MetricsContext metricsContext)
+    {
+        var tags = new TagList();
+        InitializeRateLimitingTags(ref tags, metricsContext);
+        _currentQueuedRequestsCounter.Add(1, tags);
+    }
+
+    public void QueueEnd(in MetricsContext metricsContext, RequestRejectionReason? reason, long startTimestamp, long currentTimestamp)
+    {
+        if (metricsContext.CurrentRequestsQueuedCounterEnabled || _queuedRequestDurationCounter.Enabled)
+        {
+            QueueEndCore(metricsContext, reason, startTimestamp, currentTimestamp);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private void QueueEndCore(in MetricsContext metricsContext, RequestRejectionReason? reason, long startTimestamp, long currentTimestamp)
+    {
+        var tags = new TagList();
+        InitializeRateLimitingTags(ref tags, metricsContext);
+
+        if (metricsContext.CurrentRequestsQueuedCounterEnabled)
+        {
+            _currentQueuedRequestsCounter.Add(-1, tags);
+        }
+
+        if (_queuedRequestDurationCounter.Enabled)
+        {
+            if (reason != null)
+            {
+                tags.Add("reason", reason.Value.ToString());
+            }
+            var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp);
+            _queuedRequestDurationCounter.Record(duration.TotalSeconds, tags);
+        }
+    }
+
+    public void Dispose()
+    {
+        _meter.Dispose();
+    }
+
+    private static void InitializeRateLimitingTags(ref TagList tags, in MetricsContext metricsContext)
+    {
+        if (metricsContext.PolicyName is not null)
+        {
+            tags.Add("policy", metricsContext.PolicyName);
+        }
+        if (metricsContext.Method is not null)
+        {
+            tags.Add("method", metricsContext.Method);
+        }
+        if (metricsContext.Route is not null)
+        {
+            tags.Add("route", metricsContext.Route);
+        }
+    }
+}
diff --git a/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs b/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs
index d743f77feea6..2e494b8c5449 100644
--- a/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs
+++ b/src/Middleware/RateLimiting/src/RateLimitingMiddleware.cs
@@ -1,8 +1,10 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics;
 using System.Threading.RateLimiting;
 using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Metadata;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
 
@@ -16,6 +18,7 @@ internal sealed partial class RateLimitingMiddleware
     private readonly RequestDelegate _next;
     private readonly Func<OnRejectedContext, CancellationToken, ValueTask>? _defaultOnRejected;
     private readonly ILogger _logger;
+    private readonly RateLimitingMetrics _metrics;
     private readonly PartitionedRateLimiter<HttpContext>? _globalLimiter;
     private readonly PartitionedRateLimiter<HttpContext> _endpointLimiter;
     private readonly int _rejectionStatusCode;
@@ -29,14 +32,17 @@ internal sealed partial class RateLimitingMiddleware
     /// <param name="logger">The <see cref="ILogger"/> used for logging.</param>
     /// <param name="options">The options for the middleware.</param>
     /// <param name="serviceProvider">The service provider.</param>
-    public RateLimitingMiddleware(RequestDelegate next, ILogger<RateLimitingMiddleware> logger, IOptions<RateLimiterOptions> options, IServiceProvider serviceProvider)
+    /// <param name="metrics">The rate limiting metrics.</param>
+    public RateLimitingMiddleware(RequestDelegate next, ILogger<RateLimitingMiddleware> logger, IOptions<RateLimiterOptions> options, IServiceProvider serviceProvider, RateLimitingMetrics metrics)
     {
         ArgumentNullException.ThrowIfNull(next);
         ArgumentNullException.ThrowIfNull(logger);
         ArgumentNullException.ThrowIfNull(serviceProvider);
+        ArgumentNullException.ThrowIfNull(metrics);
 
         _next = next;
         _logger = logger;
+        _metrics = metrics;
         _defaultOnRejected = options.Value.OnRejected;
         _rejectionStatusCode = options.Value.RejectionStatusCode;
         _policyMap = new Dictionary<string, DefaultRateLimiterPolicy>(options.Value.PolicyMap);
@@ -49,7 +55,6 @@ public RateLimitingMiddleware(RequestDelegate next, ILogger<RateLimitingMiddlewa
 
         _globalLimiter = options.Value.GlobalLimiter;
         _endpointLimiter = CreateEndpointLimiter();
-
     }
 
     // TODO - EventSource?
@@ -72,18 +77,47 @@ public Task Invoke(HttpContext context)
         {
             return _next(context);
         }
-        return InvokeInternal(context, enableRateLimitingAttribute);
+
+        // Only include route and method if we have an endpoint with a route.
+        // A request always has a HTTP request, but it isn't useful unless combined with a route.
+        var route = endpoint?.Metadata.GetMetadata<IRouteDiagnosticsMetadata>()?.Route;
+        var method = route is not null ? context.Request.Method : null;
+
+        return InvokeInternal(context, enableRateLimitingAttribute, method, route);
     }
 
-    private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribute? enableRateLimitingAttribute)
+    private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribute? enableRateLimitingAttribute, string? method, string? route)
     {
-        using var leaseContext = await TryAcquireAsync(context);
+        var policyName = enableRateLimitingAttribute?.PolicyName;
+
+        // Cache the up/down counter enabled state at the start of the middleware.
+        // This ensures that the state is consistent for the entire request.
+        // For example, if a meter listener starts after a request is queued, when the request exits the queue
+        // the requests queued counter won't go into a negative value.
+        var metricsContext = new MetricsContext(policyName, method, route,
+            _metrics.CurrentLeasedRequestsCounterEnabled, _metrics.CurrentQueuedRequestsCounterEnabled);
+
+        using var leaseContext = await TryAcquireAsync(context, metricsContext);
+
         if (leaseContext.Lease?.IsAcquired == true)
         {
-            await _next(context);
+            var startTimestamp = Stopwatch.GetTimestamp();
+            var currentLeaseStart = _metrics.CurrentLeasedRequestsCounterEnabled;
+            try
+            {
+
+                _metrics.LeaseStart(metricsContext);
+                await _next(context);
+            }
+            finally
+            {
+                _metrics.LeaseEnd(metricsContext, startTimestamp, Stopwatch.GetTimestamp());
+            }
         }
         else
         {
+            _metrics.LeaseFailed(metricsContext, leaseContext.RequestRejectionReason!.Value);
+
             // If the request was canceled, do not call OnRejected, just return.
             if (leaseContext.RequestRejectionReason == RequestRejectionReason.RequestCanceled)
             {
@@ -107,7 +141,6 @@ private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribu
                 }
                 else
                 {
-                    var policyName = enableRateLimitingAttribute?.PolicyName;
                     if (policyName is not null && _policyMap.TryGetValue(policyName, out policy) && policy.OnRejected is not null)
                     {
                         thisRequestOnRejected = policy.OnRejected;
@@ -122,15 +155,32 @@ private async Task InvokeInternal(HttpContext context, EnableRateLimitingAttribu
         }
     }
 
-    private ValueTask<LeaseContext> TryAcquireAsync(HttpContext context)
+    private async ValueTask<LeaseContext> TryAcquireAsync(HttpContext context, MetricsContext metricsContext)
     {
         var leaseContext = CombinedAcquire(context);
         if (leaseContext.Lease?.IsAcquired == true)
         {
-            return ValueTask.FromResult(leaseContext);
+            return leaseContext;
+        }
+
+        var waitTask = CombinedWaitAsync(context, context.RequestAborted);
+        // If the task returns immediately then the request wasn't queued.
+        if (waitTask.IsCompleted)
+        {
+            return await waitTask;
         }
 
-        return CombinedWaitAsync(context, context.RequestAborted);
+        var startTimestamp = Stopwatch.GetTimestamp();
+        try
+        {
+            _metrics.QueueStart(metricsContext);
+            leaseContext = await waitTask;
+            return leaseContext;
+        }
+        finally
+        {
+            _metrics.QueueEnd(metricsContext, leaseContext.RequestRejectionReason, startTimestamp, Stopwatch.GetTimestamp());
+        }
     }
 
     private LeaseContext CombinedAcquire(HttpContext context)
@@ -253,4 +303,4 @@ private static partial class RateLimiterLog
         [LoggerMessage(3, LogLevel.Debug, "The request was canceled.", EventName = "RequestCanceled")]
         internal static partial void RequestCanceled(ILogger logger);
     }
-}
\ No newline at end of file
+}
diff --git a/src/Middleware/RateLimiting/src/Resources.resx b/src/Middleware/RateLimiting/src/Resources.resx
new file mode 100644
index 000000000000..f50493b9d4a9
--- /dev/null
+++ b/src/Middleware/RateLimiting/src/Resources.resx
@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!-- 
+    Microsoft ResX Schema 
+    
+    Version 2.0
+    
+    The primary goals of this format is to allow a simple XML format 
+    that is mostly human readable. The generation and parsing of the 
+    various data types are done through the TypeConverter classes 
+    associated with the data types.
+    
+    Example:
+    
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+                
+    There are any number of "resheader" rows that contain simple 
+    name/value pairs.
+    
+    Each data row contains a name, and value. The row also contains a 
+    type or mimetype. Type corresponds to a .NET class that support 
+    text/value conversion through the TypeConverter architecture. 
+    Classes that don't support this are serialized and stored with the 
+    mimetype set.
+    
+    The mimetype is used for serialized objects, and tells the 
+    ResXResourceReader how to depersist the object. This is currently not 
+    extensible. For a given mimetype the value must be set accordingly:
+    
+    Note - application/x-microsoft.net.object.binary.base64 is the format 
+    that the ResXResourceWriter will generate, however the reader can 
+    read any of the formats listed below.
+    
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+    
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with 
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array 
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="UnableToFindServices" xml:space="preserve">
+    <value>Unable to find the required services. Please add all the required services by calling '{0}.{1}' in the application startup code.</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj b/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj
index 9712d186644a..22890a28eae9 100644
--- a/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj
+++ b/src/Middleware/RateLimiting/test/Microsoft.AspNetCore.RateLimiting.Tests.csproj
@@ -7,5 +7,8 @@
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Http" />
     <Reference Include="Microsoft.AspNetCore.RateLimiting" />
+    <Reference Include="Microsoft.AspNetCore.Routing" />
+
+    <Compile Include="$(SharedSourceRoot)SyncPoint\SyncPoint.cs" Link="Internal\SyncPoint.cs" />
   </ItemGroup>
 </Project>
diff --git a/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs b/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs
index 45afd2182a4d..55d4b0b3ab7b 100644
--- a/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs
+++ b/src/Middleware/RateLimiting/test/RateLimitingApplicationBuilderExtensionsTests.cs
@@ -10,7 +10,6 @@ namespace Microsoft.AspNetCore.RateLimiting;
 
 public class RateLimitingApplicationBuilderExtensionsTests : LoggedTest
 {
-
     [Fact]
     public void UseRateLimiter_ThrowsOnNullAppBuilder()
     {
@@ -24,6 +23,18 @@ public void UseRateLimiter_ThrowsOnNullOptions()
         Assert.Throws<ArgumentNullException>(() => appBuilder.UseRateLimiter(null));
     }
 
+    [Fact]
+    public void UseRateLimiter_RequireServices()
+    {
+        var services = new ServiceCollection();
+        var serviceProvider = services.BuildServiceProvider();
+        var appBuilder = new ApplicationBuilder(serviceProvider);
+
+        // Act
+        var ex = Assert.Throws<InvalidOperationException>(() => appBuilder.UseRateLimiter());
+        Assert.Equal("Unable to find the required services. Please add all the required services by calling 'IServiceCollection.AddRateLimiter' in the application startup code.", ex.Message);
+    }
+
     [Fact]
     public void UseRateLimiter_RespectsOptions()
     {
@@ -34,7 +45,7 @@ public void UseRateLimiter_RespectsOptions()
 
         // These should not get used
         var services = new ServiceCollection();
-        services.Configure<RateLimiterOptions>(options =>
+        services.AddRateLimiter(options =>
         {
             options.GlobalLimiter = new TestPartitionedRateLimiter<HttpContext>(new TestRateLimiter(false));
             options.RejectionStatusCode = 404;
diff --git a/src/Middleware/RateLimiting/test/RateLimitingMetricsTests.cs b/src/Middleware/RateLimiting/test/RateLimitingMetricsTests.cs
new file mode 100644
index 000000000000..9f5f7388daf9
--- /dev/null
+++ b/src/Middleware/RateLimiting/test/RateLimitingMetricsTests.cs
@@ -0,0 +1,347 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.Metrics;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Internal;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Metrics;
+using Microsoft.Extensions.Options;
+using Moq;
+
+namespace Microsoft.AspNetCore.RateLimiting;
+
+public class RateLimitingMetricsTests
+{
+    [Fact]
+    public async Task Metrics_Rejected()
+    {
+        // Arrange
+        var meterFactory = new TestMeterFactory();
+        var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+        var options = CreateOptionsAccessor();
+        options.Value.GlobalLimiter = new TestPartitionedRateLimiter<HttpContext>(new TestRateLimiter(false));
+
+        var middleware = CreateTestRateLimitingMiddleware(options, meterFactory: meterFactory);
+        var meter = meterFactory.Meters.Single();
+
+        var context = new DefaultHttpContext();
+
+        using var leaseRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+        using var currentLeaseRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+        using var currentRequestsQueuedRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+        using var queuedRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+        using var leaseFailedRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+        // Act
+        await middleware.Invoke(context).DefaultTimeout();
+
+        // Assert
+        Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
+
+        Assert.Empty(currentLeaseRequestsRecorder.GetMeasurements());
+        Assert.Empty(leaseRequestDurationRecorder.GetMeasurements());
+        Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+        Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+        Assert.Collection(leaseFailedRequestsRecorder.GetMeasurements(),
+            m =>
+            {
+                Assert.Equal(1, m.Value);
+                Assert.Equal("GlobalLimiter", (string)m.Tags.ToArray().Single(t => t.Key == "reason").Value);
+            });
+    }
+
+    [Fact]
+    public async Task Metrics_Success()
+    {
+        // Arrange
+        var syncPoint = new SyncPoint();
+
+        var meterFactory = new TestMeterFactory();
+        var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+        var options = CreateOptionsAccessor();
+        options.Value.GlobalLimiter = new TestPartitionedRateLimiter<HttpContext>(new TestRateLimiter(true));
+
+        var middleware = CreateTestRateLimitingMiddleware(
+            options,
+            meterFactory: meterFactory,
+            next: async c =>
+            {
+                await syncPoint.WaitToContinue();
+            });
+        var meter = meterFactory.Meters.Single();
+
+        var context = new DefaultHttpContext();
+        context.Request.Method = "GET";
+
+        using var leaseRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+        using var currentLeaseRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+        using var currentRequestsQueuedRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+        using var queuedRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+        using var leaseFailedRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+        // Act
+        var middlewareTask = middleware.Invoke(context);
+
+        await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+        Assert.Collection(currentLeaseRequestsRecorder.GetMeasurements(),
+            m => AssertCounter(m, 1, null, null, null));
+        Assert.Empty(leaseRequestDurationRecorder.GetMeasurements());
+
+        syncPoint.Continue();
+
+        await middlewareTask.DefaultTimeout();
+
+        // Assert
+        Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+
+        Assert.Collection(currentLeaseRequestsRecorder.GetMeasurements(),
+            m => AssertCounter(m, 1, null, null, null),
+            m => AssertCounter(m, -1, null, null, null));
+        Assert.Collection(leaseRequestDurationRecorder.GetMeasurements(),
+            m => AssertDuration(m, null, null, null));
+        Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+        Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+        Assert.Empty(leaseFailedRequestsRecorder.GetMeasurements());
+    }
+
+    [Fact]
+    public async Task Metrics_ListenInMiddleOfRequest_CurrentLeasesNotDecreased()
+    {
+        // Arrange
+        var syncPoint = new SyncPoint();
+
+        var meterFactory = new TestMeterFactory();
+        var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+        var options = CreateOptionsAccessor();
+        options.Value.GlobalLimiter = new TestPartitionedRateLimiter<HttpContext>(new TestRateLimiter(true));
+
+        var middleware = CreateTestRateLimitingMiddleware(
+            options,
+            meterFactory: meterFactory,
+            next: async c =>
+            {
+                await syncPoint.WaitToContinue();
+            });
+        var meter = meterFactory.Meters.Single();
+
+        var context = new DefaultHttpContext();
+        context.Request.Method = "GET";
+
+        // Act
+        var middlewareTask = middleware.Invoke(context);
+
+        await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+        using var leaseRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+        using var currentLeaseRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+        using var currentRequestsQueuedRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+        using var queuedRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+        using var leaseFailedRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+        syncPoint.Continue();
+
+        await middlewareTask.DefaultTimeout();
+
+        // Assert
+        Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+
+        Assert.Empty(currentLeaseRequestsRecorder.GetMeasurements());
+        Assert.Collection(leaseRequestDurationRecorder.GetMeasurements(),
+            m => AssertDuration(m, null, null, null));
+    }
+
+    [Fact]
+    public async Task Metrics_Queued()
+    {
+        // Arrange
+        var syncPoint = new SyncPoint();
+
+        var meterFactory = new TestMeterFactory();
+        var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+        var services = new ServiceCollection();
+
+        services.AddRateLimiter(_ => _
+            .AddConcurrencyLimiter(policyName: "concurrencyPolicy", options =>
+            {
+                options.PermitLimit = 1;
+                options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+                options.QueueLimit = 1;
+            }));
+        var serviceProvider = services.BuildServiceProvider();
+
+        var middleware = CreateTestRateLimitingMiddleware(
+            serviceProvider.GetRequiredService<IOptions<RateLimiterOptions>>(),
+            meterFactory: meterFactory,
+            next: async c =>
+            {
+                await syncPoint.WaitToContinue();
+            },
+            serviceProvider: serviceProvider);
+        var meter = meterFactory.Meters.Single();
+
+        var routeEndpointBuilder = new RouteEndpointBuilder(c => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
+        routeEndpointBuilder.Metadata.Add(new EnableRateLimitingAttribute("concurrencyPolicy"));
+        var endpoint = routeEndpointBuilder.Build();
+
+        using var leaseRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+        using var currentLeaseRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+        using var currentRequestsQueuedRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+        using var queuedRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+        using var leaseFailedRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+        // Act
+        var context1 = new DefaultHttpContext();
+        context1.Request.Method = "GET";
+        context1.SetEndpoint(endpoint);
+        var middlewareTask1 = middleware.Invoke(context1);
+
+        // Wait for first request to reach server and block it.
+        await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+        var context2 = new DefaultHttpContext();
+        context2.Request.Method = "GET";
+        context2.SetEndpoint(endpoint);
+        var middlewareTask2 = middleware.Invoke(context1);
+
+        // Assert second request is queued.
+        Assert.Collection(currentRequestsQueuedRecorder.GetMeasurements(),
+            m => AssertCounter(m, 1, "GET", "/", "concurrencyPolicy"));
+        Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+
+        // Allow both requests to finish.
+        syncPoint.Continue();
+
+        await middlewareTask1.DefaultTimeout();
+        await middlewareTask2.DefaultTimeout();
+
+        Assert.Collection(currentRequestsQueuedRecorder.GetMeasurements(),
+            m => AssertCounter(m, 1, "GET", "/", "concurrencyPolicy"),
+            m => AssertCounter(m, -1, "GET", "/", "concurrencyPolicy"));
+        Assert.Collection(queuedRequestDurationRecorder.GetMeasurements(),
+            m => AssertDuration(m, "GET", "/", "concurrencyPolicy"));
+    }
+
+    [Fact]
+    public async Task Metrics_ListenInMiddleOfQueued_CurrentQueueNotDecreased()
+    {
+        // Arrange
+        var syncPoint = new SyncPoint();
+
+        var meterFactory = new TestMeterFactory();
+        var meterRegistry = new TestMeterRegistry(meterFactory.Meters);
+
+        var services = new ServiceCollection();
+
+        services.AddRateLimiter(_ => _
+            .AddConcurrencyLimiter(policyName: "concurrencyPolicy", options =>
+            {
+                options.PermitLimit = 1;
+                options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
+                options.QueueLimit = 1;
+            }));
+        var serviceProvider = services.BuildServiceProvider();
+
+        var middleware = CreateTestRateLimitingMiddleware(
+            serviceProvider.GetRequiredService<IOptions<RateLimiterOptions>>(),
+            meterFactory: meterFactory,
+            next: async c =>
+            {
+                await syncPoint.WaitToContinue();
+            },
+            serviceProvider: serviceProvider);
+        var meter = meterFactory.Meters.Single();
+
+        var routeEndpointBuilder = new RouteEndpointBuilder(c => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
+        routeEndpointBuilder.Metadata.Add(new EnableRateLimitingAttribute("concurrencyPolicy"));
+        var endpoint = routeEndpointBuilder.Build();
+
+        // Act
+        var context1 = new DefaultHttpContext();
+        context1.Request.Method = "GET";
+        context1.SetEndpoint(endpoint);
+        var middlewareTask1 = middleware.Invoke(context1);
+
+        // Wait for first request to reach server and block it.
+        await syncPoint.WaitForSyncPoint().DefaultTimeout();
+
+        var context2 = new DefaultHttpContext();
+        context2.Request.Method = "GET";
+        context2.SetEndpoint(endpoint);
+        var middlewareTask2 = middleware.Invoke(context1);
+
+        // Start listening while the second request is queued.
+
+        using var leaseRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "leased-request-duration");
+        using var currentLeaseRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-leased-requests");
+        using var currentRequestsQueuedRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "current-queued-requests");
+        using var queuedRequestDurationRecorder = new InstrumentRecorder<double>(meterRegistry, RateLimitingMetrics.MeterName, "queued-request-duration");
+        using var leaseFailedRequestsRecorder = new InstrumentRecorder<long>(meterRegistry, RateLimitingMetrics.MeterName, "lease-failed-requests");
+
+        Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+        Assert.Empty(queuedRequestDurationRecorder.GetMeasurements());
+
+        // Allow both requests to finish.
+        syncPoint.Continue();
+
+        await middlewareTask1.DefaultTimeout();
+        await middlewareTask2.DefaultTimeout();
+
+        Assert.Empty(currentRequestsQueuedRecorder.GetMeasurements());
+        Assert.Collection(queuedRequestDurationRecorder.GetMeasurements(),
+            m => AssertDuration(m, "GET", "/", "concurrencyPolicy"));
+    }
+
+    private static void AssertCounter(Measurement<long> measurement, long value, string method, string route, string policy)
+    {
+        Assert.Equal(value, measurement.Value);
+        AssertTag(measurement.Tags, "method", method);
+        AssertTag(measurement.Tags, "route", route);
+        AssertTag(measurement.Tags, "policy", policy);
+    }
+
+    private static void AssertDuration(Measurement<double> measurement, string method, string route, string policy)
+    {
+        Assert.True(measurement.Value > 0);
+        AssertTag(measurement.Tags, "method", method);
+        AssertTag(measurement.Tags, "route", route);
+        AssertTag(measurement.Tags, "policy", policy);
+    }
+
+    private static void AssertTag<T>(ReadOnlySpan<KeyValuePair<string, object>> tags, string tagName, T expected)
+    {
+        if (expected == null)
+        {
+            Assert.DoesNotContain(tags.ToArray(), t => t.Key == tagName);
+        }
+        else
+        {
+            Assert.Equal(expected, (T)tags.ToArray().Single(t => t.Key == tagName).Value);
+        }
+    }
+
+    private RateLimitingMiddleware CreateTestRateLimitingMiddleware(IOptions<RateLimiterOptions> options, ILogger<RateLimitingMiddleware> logger = null, IServiceProvider serviceProvider = null, IMeterFactory meterFactory = null, RequestDelegate next = null)
+    {
+        next ??= c => Task.CompletedTask;
+        return new RateLimitingMiddleware(
+            next,
+            logger ?? new NullLoggerFactory().CreateLogger<RateLimitingMiddleware>(),
+            options,
+            serviceProvider ?? Mock.Of<IServiceProvider>(),
+            new RateLimitingMetrics(meterFactory ?? new TestMeterFactory()));
+    }
+
+    private IOptions<RateLimiterOptions> CreateOptionsAccessor() => Options.Create(new RateLimiterOptions());
+}
diff --git a/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs b/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs
index 24c6ccd93990..b64c7a258195 100644
--- a/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs
+++ b/src/Middleware/RateLimiting/test/RateLimitingMiddlewareTests.cs
@@ -8,6 +8,7 @@
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 using Microsoft.Extensions.Logging.Testing;
+using Microsoft.Extensions.Metrics;
 using Microsoft.Extensions.Options;
 using Moq;
 
@@ -25,7 +26,8 @@ public void Ctor_ThrowsExceptionsWhenNullArgs()
             null,
             new NullLoggerFactory().CreateLogger<RateLimitingMiddleware>(),
             options,
-            Mock.Of<IServiceProvider>()));
+            Mock.Of<IServiceProvider>(),
+            new RateLimitingMetrics(new TestMeterFactory())));
 
         Assert.Throws<ArgumentNullException>(() => new RateLimitingMiddleware(c =>
             {
@@ -33,7 +35,8 @@ public void Ctor_ThrowsExceptionsWhenNullArgs()
             },
             null,
             options,
-            Mock.Of<IServiceProvider>()));
+            Mock.Of<IServiceProvider>(),
+            new RateLimitingMetrics(new TestMeterFactory())));
 
         Assert.Throws<ArgumentNullException>(() => new RateLimitingMiddleware(c =>
             {
@@ -41,6 +44,16 @@ public void Ctor_ThrowsExceptionsWhenNullArgs()
             },
             new NullLoggerFactory().CreateLogger<RateLimitingMiddleware>(),
             options,
+            null,
+            new RateLimitingMetrics(new TestMeterFactory())));
+
+        Assert.Throws<ArgumentNullException>(() => new RateLimitingMiddleware(c =>
+            {
+                return Task.CompletedTask;
+            },
+            new NullLoggerFactory().CreateLogger<RateLimitingMiddleware>(),
+            options,
+            Mock.Of<IServiceProvider>(),
             null));
     }
 
@@ -59,7 +72,8 @@ public async Task RequestsCallNextIfAccepted()
             },
             new NullLoggerFactory().CreateLogger<RateLimitingMiddleware>(),
             options,
-            Mock.Of<IServiceProvider>());
+            Mock.Of<IServiceProvider>(),
+            new RateLimitingMetrics(new TestMeterFactory()));
 
         // Act
         await middleware.Invoke(new DefaultHttpContext());
@@ -637,7 +651,8 @@ private RateLimitingMiddleware CreateTestRateLimitingMiddleware(IOptions<RateLim
         },
             logger ?? new NullLoggerFactory().CreateLogger<RateLimitingMiddleware>(),
             options,
-            serviceProvider ?? Mock.Of<IServiceProvider>());
+            serviceProvider ?? Mock.Of<IServiceProvider>(),
+            new RateLimitingMetrics(new TestMeterFactory()));
 
     private IOptions<RateLimiterOptions> CreateOptionsAccessor() => Options.Create(new RateLimiterOptions());
 }
diff --git a/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs b/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs
index fa19f79779b7..f84dc427e692 100644
--- a/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs
+++ b/src/Middleware/RateLimiting/test/TestPartitionedRateLimiter.cs
@@ -12,18 +12,18 @@ namespace Microsoft.AspNetCore.RateLimiting;
 
 internal class TestPartitionedRateLimiter<TResource> : PartitionedRateLimiter<TResource>
 {
-    private List<RateLimiter> limiters = new List<RateLimiter>();
+    private readonly List<RateLimiter> _limiters = new List<RateLimiter>();
 
     public TestPartitionedRateLimiter() { }
 
     public TestPartitionedRateLimiter(RateLimiter limiter)
     {
-        limiters.Add(limiter);
+        _limiters.Add(limiter);
     }
 
     public void AddLimiter(RateLimiter limiter)
     {
-        limiters.Add(limiter);
+        _limiters.Add(limiter);
     }
 
     public override RateLimiterStatistics GetStatistics(TResource resourceID)
@@ -36,9 +36,9 @@ protected override RateLimitLease AttemptAcquireCore(TResource resourceID, int p
         if (permitCount != 1)
         {
             throw new ArgumentException("Tests only support 1 permit at a time");
-        }    
+        }
         var leases = new List<RateLimitLease>();
-        foreach (var limiter in limiters)
+        foreach (var limiter in _limiters)
         {
             var lease = limiter.AttemptAcquire();
             if (lease.IsAcquired)
@@ -64,7 +64,7 @@ protected override async ValueTask<RateLimitLease> AcquireAsyncCore(TResource re
             throw new ArgumentException("Tests only support 1 permit at a time");
         }
         var leases = new List<RateLimitLease>();
-        foreach (var limiter in limiters)
+        foreach (var limiter in _limiters)
         {
             leases.Add(await limiter.AcquireAsync());
         }
@@ -77,9 +77,8 @@ protected override async ValueTask<RateLimitLease> AcquireAsyncCore(TResource re
                     unusedLease.Dispose();
                 }
                 return new TestRateLimitLease(false, null);
-            }    
+            }
         }
         return new TestRateLimitLease(true, leases);
-
     }
 }
diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
index f41aa21dc843..e4b328c02a61 100644
--- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
+++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj
@@ -20,8 +20,6 @@
     <Compile Include="$(KestrelSharedSourceRoot)\HPackHeaderWriter.cs" Link="Internal\Http2\HPackHeaderWriter.cs" />
     <Compile Include="$(KestrelSharedSourceRoot)\Http2HeadersEnumerator.cs" Link="Internal\Http2\Http2HeadersEnumerator.cs" />
     <Compile Include="$(SharedSourceRoot)CertificateGeneration\**\*.cs" />
-    <Compile Include="$(SharedSourceRoot)ValueTaskExtensions\**\*.cs" />
-    <Compile Include="$(SharedSourceRoot)UrlDecoder\**\*.cs" />
     <Compile Include="$(SharedSourceRoot)InternalHeaderNames.cs" Link="Shared\InternalHeaderNames.cs"/>
     <Compile Include="$(SharedSourceRoot)Buffers\**\*.cs" LinkBase="Internal\Infrastructure\PipeWriterHelpers" />
     <Compile Include="$(SharedSourceRoot)runtime\*.cs" Link="Shared\runtime\%(Filename)%(Extension)" />
diff --git a/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs b/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs
index 0109d41a2d42..0edf0ee3ea5d 100644
--- a/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs
+++ b/src/Servers/Kestrel/test/Interop.FunctionalTests/Http2/Http2RequestTests.cs
@@ -115,6 +115,7 @@ public async Task GET_RequestReturnsLargeData_GracefulShutdownDuringRequest_Requ
     private static async Task<(byte[], HttpResponseHeaders)> StartLongRunningRequestAsync(ILogger logger, IHost host, HttpMessageInvoker client)
     {
         var request = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{host.GetPort()}/");
+        request.Headers.Host = "localhost2";
         request.Version = HttpVersion.Version20;
         request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;