Skip to content

Commit

Permalink
Merge pull request #2026 from bcgov/metrics
Browse files Browse the repository at this point in the history
Metrics & etags
  • Loading branch information
pbolduc authored Dec 11, 2024
2 parents ed61335 + 9ca475a commit ba2149e
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Security.Cryptography;
using System.Text;

namespace TrafficCourts.Staff.Service.Middleware;

public class ETagMiddleware
{
private readonly RequestDelegate _next;

public ETagMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext context)
{
// Only handle GET requests
if (context.Request.Method != HttpMethods.Get)
{
await _next(context);
return;
}

// capture the response body
Stream originalBodyStream = context.Response.Body;

// create new response body stream
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await _next(context);

// Only generate ETag for successful responses
if (context.Response.StatusCode == StatusCodes.Status200OK)
{
// Generate ETag based on the response content
memoryStream.Seek(0, SeekOrigin.Begin);
var content = await new StreamReader(memoryStream).ReadToEndAsync();
var eTag = GenerateETag(content);

// Check the If-None-Match header
if (context.Request.Headers.TryGetValue("If-None-Match", out Microsoft.Extensions.Primitives.StringValues value) && value == eTag)
{
context.Response.StatusCode = StatusCodes.Status304NotModified;
context.Response.Headers.ETag = eTag;
context.Response.Body = originalBodyStream;
context.Response.ContentLength = 0; // Ensure the response body is empty
return;
}

// Include the ETag in the response headers
context.Response.Headers.ETag = eTag;

// Write the response body back to the original stream
memoryStream.Seek(0, SeekOrigin.Begin);
await memoryStream.CopyToAsync(originalBodyStream);
}

context.Response.Body = originalBodyStream;
}

private string GenerateETag(string content)
{
using (var md5 = MD5.Create())
{
var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(content));
return $"\"{Convert.ToBase64String(hash)}\"";
}
}
}
3 changes: 3 additions & 0 deletions src/backend/TrafficCourts/Staff.Service/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using TrafficCourts.Configuration.Validation;
using TrafficCourts.Diagnostics;
using TrafficCourts.Staff.Service;
using TrafficCourts.Staff.Service.Middleware;

var builder = WebApplication.CreateBuilder(args);
var logger = builder.GetProgramLogger();
Expand All @@ -18,6 +19,8 @@
app.UseAuthentication();
app.UseAuthorization();

app.UseMiddleware<ETagMiddleware>();

app.UseFastEndpoints(c => {
c.Endpoints.RoutePrefix = "api";
c.Endpoints.PrefixNameWithFirstTag = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ protected OperationMetrics(IMeterFactory factory, string meterName, string name,
_timer = new Timer(_meter, $"{name}.operation.duration", "ms", $"Elapsed time spent executing a {description} operation");
}

protected Meter Meter => _meter;

/// <summary>
/// Begins a named operation
/// </summary>
Expand Down Expand Up @@ -62,6 +64,6 @@ public ITimerOperation BeginOperation([CallerMemberName] string operation = null

public void Dispose()
{
_meter.Dispose();
Meter.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Caching.Memory;
using OpenTelemetry.Metrics;
using System.Diagnostics.Metrics;

namespace TrafficCourts.OrdsDataService;
Expand Down Expand Up @@ -33,17 +34,12 @@ public class ETagHandler : DelegatingHandler
public const string NoCache = "no-cache";

private readonly IMemoryCache _cache;
private readonly IOrdsDataServiceOperationMetrics _metrics;

private readonly Counter<long> _cacheHitCounter;
private readonly Counter<long> _cacheMissCounter;

public ETagHandler(IMemoryCache cache, IMeterFactory factory)
internal ETagHandler(IMemoryCache cache, IOrdsDataServiceOperationMetrics metrics)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));

var meter = factory.Create(OrdsDataServiceOperationMetrics.MeterName);
_cacheHitCounter = meter.CreateCounter<long>("ordsdataservice_etag_cache_hits");
_cacheMissCounter = meter.CreateCounter<long>("ordsdataservice_etag_cache_misses");
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
Expand All @@ -69,20 +65,19 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
var pathTag = new KeyValuePair<string, object?>("path", request.RequestUri!.AbsolutePath);
var expirationTag = new KeyValuePair<string, object?>("expiration-secs", (int)expiration.TotalSeconds);

// already had the response
// already have the response
if (response.StatusCode == System.Net.HttpStatusCode.NotModified)
{
_cacheHitCounter.Add(1, pathTag, expirationTag);
_metrics.RecordEtagCacheHit(pathTag, expirationTag);
response = CreateResponse(request, cacheEntry);
return response;
}
else
{
_cacheMissCounter.Add(1, pathTag, expirationTag); // this same request was different than the cached response
}

if (response.Headers.ETag != null)
{
// only increment the cache miss if we have a etag
_metrics.RecordEtagCacheMiss(pathTag, expirationTag);

var responseBody = await response.Content.ReadAsByteArrayAsync(cancellationToken);

// create response template
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@ namespace TrafficCourts.OrdsDataService;

internal interface IOrdsDataServiceOperationMetrics : IOperationMetrics
{
/// <summary>
/// Records a OrdsDataService ETag cache hit.
/// </summary>
void RecordEtagCacheHit(KeyValuePair<string, object?> tag1, KeyValuePair<string, object?> tag2);

/// <summary>
/// Records a OrdsDataService ETag cache miss.
/// </summary>
void RecordEtagCacheMiss(KeyValuePair<string, object?> tag1, KeyValuePair<string, object?> tag2);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public static void AddOrdsDataService(this IServiceCollection services, IConfigu
.AddHttpMessageHandler(sp =>
{
var cache = sp.GetRequiredService<Caching.Memory.IMemoryCache>();
var factory = sp.GetRequiredService<IMeterFactory>();
ETagHandler handler = new ETagHandler(cache, factory);
var metrics = sp.GetRequiredService<IOrdsDataServiceOperationMetrics>();
ETagHandler handler = new ETagHandler(cache, metrics);
return handler;
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,23 @@ internal class OrdsDataServiceOperationMetrics : OperationMetrics, IOrdsDataServ
{
public const string MeterName = "OrdsDataService";

private readonly Counter<long> _cacheHitCounter;
private readonly Counter<long> _cacheMissCounter;

public OrdsDataServiceOperationMetrics(IMeterFactory factory) : base(factory, MeterName, "ordsdataservice", "ORDS Data Service")
{
_cacheHitCounter = Meter.CreateCounter<long>("ordsdataservice.etag.cache.hits");
_cacheMissCounter = Meter.CreateCounter<long>("ordsdataservice.etag.cache.misses");
}

/// <summary>
/// Records a OrdsDataService ETag cache hit.
/// </summary>
public void RecordEtagCacheHit(KeyValuePair<string, object?> tag1, KeyValuePair<string, object?> tag2) => _cacheHitCounter.Add(1, tag1, tag2);
/// <summary>
/// Records a OrdsDataService ETag cache miss.
/// </summary>
public void RecordEtagCacheMiss(KeyValuePair<string, object?> tag1, KeyValuePair<string, object?> tag2) => _cacheMissCounter.Add(1, tag1, tag2);
}

public static class TracerProviderBuilderExtensions
Expand Down

0 comments on commit ba2149e

Please sign in to comment.