Skip to content

Commit d208dcc

Browse files
Copilotjongalloway
andcommitted
Add comprehensive metrics collection and rate limiting with middleware
Co-authored-by: jongalloway <[email protected]>
1 parent 38461b9 commit d208dcc

File tree

9 files changed

+595
-0
lines changed

9 files changed

+595
-0
lines changed

src/NLWebNet/Extensions/ApplicationBuilderExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public static class ApplicationBuilderExtensions
1717
/// <returns>The application builder for chaining</returns>
1818
public static IApplicationBuilder UseNLWebNet(this IApplicationBuilder app)
1919
{
20+
app.UseMiddleware<RateLimitingMiddleware>();
21+
app.UseMiddleware<MetricsMiddleware>();
2022
app.UseMiddleware<NLWebMiddleware>();
2123
return app;
2224
} /// <summary>

src/NLWebNet/Extensions/ServiceCollectionExtensions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using NLWebNet.MCP;
66
using NLWebNet.Controllers;
77
using NLWebNet.Health;
8+
using NLWebNet.RateLimiting;
89

910
namespace NLWebNet;
1011

@@ -46,6 +47,12 @@ public static IServiceCollection AddNLWebNet(this IServiceCollection services, A
4647
.AddCheck<DataBackendHealthCheck>("data-backend")
4748
.AddCheck<AIServiceHealthCheck>("ai-service");
4849

50+
// Add metrics
51+
services.AddMetrics();
52+
53+
// Add rate limiting
54+
services.AddSingleton<IRateLimitingService, InMemoryRateLimitingService>();
55+
4956
return services;
5057
}
5158

@@ -82,6 +89,12 @@ public static IServiceCollection AddNLWebNet<TDataBackend>(this IServiceCollecti
8289
.AddCheck<DataBackendHealthCheck>("data-backend")
8390
.AddCheck<AIServiceHealthCheck>("ai-service");
8491

92+
// Add metrics
93+
services.AddMetrics();
94+
95+
// Add rate limiting
96+
services.AddSingleton<IRateLimitingService, InMemoryRateLimitingService>();
97+
8598
return services;
8699
}
87100
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Diagnostics.Metrics;
2+
3+
namespace NLWebNet.Metrics;
4+
5+
/// <summary>
6+
/// Contains metric definitions and constants for NLWebNet monitoring
7+
/// </summary>
8+
public static class NLWebMetrics
9+
{
10+
/// <summary>
11+
/// The meter name for NLWebNet metrics
12+
/// </summary>
13+
public const string MeterName = "NLWebNet";
14+
15+
/// <summary>
16+
/// The version for metrics tracking
17+
/// </summary>
18+
public const string Version = "1.0.0";
19+
20+
/// <summary>
21+
/// Shared meter instance for all NLWebNet metrics
22+
/// </summary>
23+
public static readonly Meter Meter = new(MeterName, Version);
24+
25+
// Request/Response Metrics
26+
public static readonly Counter<long> RequestCount = Meter.CreateCounter<long>(
27+
"nlweb.requests.total",
28+
description: "Total number of requests processed");
29+
30+
public static readonly Histogram<double> RequestDuration = Meter.CreateHistogram<double>(
31+
"nlweb.request.duration",
32+
unit: "ms",
33+
description: "Duration of request processing in milliseconds");
34+
35+
public static readonly Counter<long> RequestErrors = Meter.CreateCounter<long>(
36+
"nlweb.requests.errors",
37+
description: "Total number of request errors");
38+
39+
// AI Service Metrics
40+
public static readonly Counter<long> AIServiceCalls = Meter.CreateCounter<long>(
41+
"nlweb.ai.calls.total",
42+
description: "Total number of AI service calls");
43+
44+
public static readonly Histogram<double> AIServiceDuration = Meter.CreateHistogram<double>(
45+
"nlweb.ai.duration",
46+
unit: "ms",
47+
description: "Duration of AI service calls in milliseconds");
48+
49+
public static readonly Counter<long> AIServiceErrors = Meter.CreateCounter<long>(
50+
"nlweb.ai.errors",
51+
description: "Total number of AI service errors");
52+
53+
// Data Backend Metrics
54+
public static readonly Counter<long> DataBackendQueries = Meter.CreateCounter<long>(
55+
"nlweb.data.queries.total",
56+
description: "Total number of data backend queries");
57+
58+
public static readonly Histogram<double> DataBackendDuration = Meter.CreateHistogram<double>(
59+
"nlweb.data.duration",
60+
unit: "ms",
61+
description: "Duration of data backend operations in milliseconds");
62+
63+
public static readonly Counter<long> DataBackendErrors = Meter.CreateCounter<long>(
64+
"nlweb.data.errors",
65+
description: "Total number of data backend errors");
66+
67+
// Health Check Metrics
68+
public static readonly Counter<long> HealthCheckExecutions = Meter.CreateCounter<long>(
69+
"nlweb.health.checks.total",
70+
description: "Total number of health check executions");
71+
72+
public static readonly Counter<long> HealthCheckFailures = Meter.CreateCounter<long>(
73+
"nlweb.health.failures",
74+
description: "Total number of health check failures");
75+
76+
// Business Metrics
77+
public static readonly Counter<long> QueryTypeCount = Meter.CreateCounter<long>(
78+
"nlweb.queries.by_type",
79+
description: "Count of queries by type (List, Summarize, Generate)");
80+
81+
public static readonly Histogram<double> QueryComplexity = Meter.CreateHistogram<double>(
82+
"nlweb.queries.complexity",
83+
description: "Query complexity score based on length and structure");
84+
85+
/// <summary>
86+
/// Common tag keys for consistent metric labeling
87+
/// </summary>
88+
public static class Tags
89+
{
90+
public const string Endpoint = "endpoint";
91+
public const string Method = "method";
92+
public const string StatusCode = "status_code";
93+
public const string QueryMode = "query_mode";
94+
public const string ErrorType = "error_type";
95+
public const string HealthCheckName = "health_check";
96+
public const string DataBackendType = "backend_type";
97+
public const string AIServiceType = "ai_service_type";
98+
}
99+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Extensions.Logging;
3+
using NLWebNet.Metrics;
4+
using System.Diagnostics;
5+
6+
namespace NLWebNet.Middleware;
7+
8+
/// <summary>
9+
/// Middleware for collecting metrics on HTTP requests
10+
/// </summary>
11+
public class MetricsMiddleware
12+
{
13+
private readonly RequestDelegate _next;
14+
private readonly ILogger<MetricsMiddleware> _logger;
15+
16+
public MetricsMiddleware(RequestDelegate next, ILogger<MetricsMiddleware> logger)
17+
{
18+
_next = next ?? throw new ArgumentNullException(nameof(next));
19+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
20+
}
21+
22+
public async Task InvokeAsync(HttpContext context)
23+
{
24+
var stopwatch = Stopwatch.StartNew();
25+
var path = context.Request.Path.Value ?? "unknown";
26+
var method = context.Request.Method;
27+
28+
try
29+
{
30+
await _next(context);
31+
}
32+
catch (Exception ex)
33+
{
34+
// Record error metrics
35+
NLWebMetrics.RequestErrors.Add(1,
36+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.Endpoint, path),
37+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.Method, method),
38+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.ErrorType, ex.GetType().Name));
39+
40+
_logger.LogError(ex, "Request failed for {Method} {Path}", method, path);
41+
throw;
42+
}
43+
finally
44+
{
45+
stopwatch.Stop();
46+
var duration = stopwatch.Elapsed.TotalMilliseconds;
47+
var statusCode = context.Response.StatusCode.ToString();
48+
49+
// Record request metrics
50+
NLWebMetrics.RequestCount.Add(1,
51+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.Endpoint, path),
52+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.Method, method),
53+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.StatusCode, statusCode));
54+
55+
NLWebMetrics.RequestDuration.Record(duration,
56+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.Endpoint, path),
57+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.Method, method),
58+
new KeyValuePair<string, object?>(NLWebMetrics.Tags.StatusCode, statusCode));
59+
60+
_logger.LogDebug("Request {Method} {Path} completed in {Duration}ms with status {StatusCode}",
61+
method, path, duration, statusCode);
62+
}
63+
}
64+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Extensions.Logging;
3+
using Microsoft.Extensions.Options;
4+
using NLWebNet.RateLimiting;
5+
using System.Text.Json;
6+
7+
namespace NLWebNet.Middleware;
8+
9+
/// <summary>
10+
/// Middleware for enforcing rate limits on requests
11+
/// </summary>
12+
public class RateLimitingMiddleware
13+
{
14+
private readonly RequestDelegate _next;
15+
private readonly IRateLimitingService _rateLimitingService;
16+
private readonly RateLimitingOptions _options;
17+
private readonly ILogger<RateLimitingMiddleware> _logger;
18+
19+
public RateLimitingMiddleware(
20+
RequestDelegate next,
21+
IRateLimitingService rateLimitingService,
22+
IOptions<RateLimitingOptions> options,
23+
ILogger<RateLimitingMiddleware> logger)
24+
{
25+
_next = next ?? throw new ArgumentNullException(nameof(next));
26+
_rateLimitingService = rateLimitingService ?? throw new ArgumentNullException(nameof(rateLimitingService));
27+
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
28+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
29+
}
30+
31+
public async Task InvokeAsync(HttpContext context)
32+
{
33+
if (!_options.Enabled)
34+
{
35+
await _next(context);
36+
return;
37+
}
38+
39+
var identifier = GetClientIdentifier(context);
40+
var isAllowed = await _rateLimitingService.IsRequestAllowedAsync(identifier);
41+
42+
if (!isAllowed)
43+
{
44+
await HandleRateLimitExceeded(context, identifier);
45+
return;
46+
}
47+
48+
// Add rate limit headers
49+
var status = await _rateLimitingService.GetRateLimitStatusAsync(identifier);
50+
context.Response.Headers.Append("X-RateLimit-Limit", _options.RequestsPerWindow.ToString());
51+
context.Response.Headers.Append("X-RateLimit-Remaining", status.RequestsRemaining.ToString());
52+
context.Response.Headers.Append("X-RateLimit-Reset", ((int)status.WindowResetTime.TotalSeconds).ToString());
53+
54+
await _next(context);
55+
}
56+
57+
private string GetClientIdentifier(HttpContext context)
58+
{
59+
// Try client ID header first if enabled
60+
if (_options.EnableClientBasedLimiting)
61+
{
62+
var clientId = context.Request.Headers[_options.ClientIdHeader].FirstOrDefault();
63+
if (!string.IsNullOrEmpty(clientId))
64+
{
65+
return $"client:{clientId}";
66+
}
67+
}
68+
69+
// Fall back to IP-based limiting if enabled
70+
if (_options.EnableIPBasedLimiting)
71+
{
72+
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
73+
return $"ip:{ip}";
74+
}
75+
76+
// Default fallback
77+
return "default";
78+
}
79+
80+
private async Task HandleRateLimitExceeded(HttpContext context, string identifier)
81+
{
82+
var status = await _rateLimitingService.GetRateLimitStatusAsync(identifier);
83+
84+
context.Response.StatusCode = 429; // Too Many Requests
85+
context.Response.Headers.Append("X-RateLimit-Limit", _options.RequestsPerWindow.ToString());
86+
context.Response.Headers.Append("X-RateLimit-Remaining", "0");
87+
context.Response.Headers.Append("X-RateLimit-Reset", ((int)status.WindowResetTime.TotalSeconds).ToString());
88+
context.Response.Headers.Append("Retry-After", ((int)status.WindowResetTime.TotalSeconds).ToString());
89+
90+
var response = new
91+
{
92+
error = "rate_limit_exceeded",
93+
message = $"Rate limit exceeded. Maximum {_options.RequestsPerWindow} requests per {_options.WindowSizeInMinutes} minute(s).",
94+
retry_after_seconds = (int)status.WindowResetTime.TotalSeconds
95+
};
96+
97+
context.Response.ContentType = "application/json";
98+
await context.Response.WriteAsync(JsonSerializer.Serialize(response));
99+
100+
_logger.LogWarning("Rate limit exceeded for identifier {Identifier}. Requests: {Requests}/{Limit}",
101+
identifier, status.TotalRequests, _options.RequestsPerWindow);
102+
}
103+
}

src/NLWebNet/Models/NLWebOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.ComponentModel.DataAnnotations;
2+
using NLWebNet.RateLimiting;
23

34
namespace NLWebNet.Models;
45

@@ -65,4 +66,9 @@ public class NLWebOptions
6566
/// </summary>
6667
[Range(1, 1440)]
6768
public int CacheExpirationMinutes { get; set; } = 60;
69+
70+
/// <summary>
71+
/// Rate limiting configuration
72+
/// </summary>
73+
public RateLimitingOptions RateLimiting { get; set; } = new();
6874
}

0 commit comments

Comments
 (0)