Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/RiftCache/Caching/CacheMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Diagnostics.Metrics;

namespace RiftCache.Caching;

/// <summary>
/// Cache hit/miss counters and a live entry-count gauge, published via System.Diagnostics.Metrics
/// so the OpenTelemetry SDK picks them up once MeterName is registered with .AddMeter(...).
/// </summary>
public sealed class CacheMetrics
{
public const string MeterName = "RiftCache.Caching";

private readonly Meter _meter;
private readonly Counter<long> _hits;
private readonly Counter<long> _misses;

public CacheMetrics(InMemoryCacheStore store)
{
_meter = new Meter(MeterName);
_hits = _meter.CreateCounter<long>("riftcache.cache.hits", description: "Cache reads that found a live entry.");
_misses = _meter.CreateCounter<long>("riftcache.cache.misses", description: "Cache reads that found nothing, or an expired entry.");
_meter.CreateObservableGauge("riftcache.cache.entries", () => store.Count, description: "Entries currently held in the in-memory store.");
}

/// <summary>Exposed for tests to filter a MeterListener by exact instance rather than just MeterName, since multiple CacheMetrics instances (one per test) share that name.</summary>
internal Meter Meter => _meter;

public void RecordHit() => _hits.Add(1);

public void RecordMiss() => _misses.Add(1);
}
35 changes: 35 additions & 0 deletions src/RiftCache/Caching/MeteredCacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace RiftCache.Caching;

/// <summary>
/// ICacheStore decorator that records hit/miss metrics around GetAsync. Kept separate from
/// InMemoryCacheStore rather than injecting CacheMetrics directly into it, since CacheMetrics
/// itself depends on InMemoryCacheStore (for the entry-count gauge) -- injecting it the other way
/// too would create a circular dependency.
/// </summary>
internal sealed class MeteredCacheStore(ICacheStore inner, CacheMetrics metrics) : ICacheStore
{
public async Task<byte[]?> GetAsync(string key, CancellationToken token = default)
{
var value = await inner.GetAsync(key, token).ConfigureAwait(false);

if (value is null)
{
metrics.RecordMiss();
}
else
{
metrics.RecordHit();
}

return value;
}

public Task SetAsync(string key, byte[] value, DateTimeOffset? absoluteExpiration, TimeSpan? slidingExpiration, CancellationToken token = default) =>
inner.SetAsync(key, value, absoluteExpiration, slidingExpiration, token);

public Task<bool> RefreshAsync(string key, CancellationToken token = default) =>
inner.RefreshAsync(key, token);

public Task<bool> RemoveAsync(string key, CancellationToken token = default) =>
inner.RemoveAsync(key, token);
}
35 changes: 34 additions & 1 deletion src/RiftCache/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Microsoft.Extensions.Options;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using RiftCache.Caching;
using RiftCache.Endpoints;
using RiftCache.Options;
Expand All @@ -13,9 +16,39 @@
builder.Services.AddSingleton<ISecretProvider, EnvironmentSecretProvider>();
builder.Services.AddSingleton<IPersistenceProvider, NullPersistenceProvider>();
builder.Services.AddSingleton<InMemoryCacheStore>();
builder.Services.AddSingleton<ICacheStore>(sp => sp.GetRequiredService<InMemoryCacheStore>());
builder.Services.AddSingleton<CacheMetrics>();
builder.Services.AddSingleton<ICacheStore>(sp =>
new MeteredCacheStore(sp.GetRequiredService<InMemoryCacheStore>(), sp.GetRequiredService<CacheMetrics>()));
builder.Services.AddHostedService<CacheEvictionService>();

// Only export via OTLP when an endpoint is actually configured -- otherwise a self-hoster who set
// nothing gets zero telemetry noise (no attempted connections, no connection-refused warnings).
// Read through IConfiguration, not Environment.GetEnvironmentVariable, for the same reason
// EnvironmentSecretProvider does: picks up env vars, user secrets, and config files uniformly.
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];

builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("RiftCache"))
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation();

if (!string.IsNullOrEmpty(otlpEndpoint))
{
tracing.AddOtlpExporter();
}
})
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation();
metrics.AddMeter(CacheMetrics.MeterName);

if (!string.IsNullOrEmpty(otlpEndpoint))
{
metrics.AddOtlpExporter();
}
});

var app = builder.Build();

var serverOptions = app.Services.GetRequiredService<IOptions<RiftCacheServerOptions>>().Value;
Expand Down
6 changes: 6 additions & 0 deletions src/RiftCache/RiftCache.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@
<InternalsVisibleTo Include="RiftCache.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
</ItemGroup>

</Project>
82 changes: 82 additions & 0 deletions tests/RiftCache.Tests/Caching/CacheMetricsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Diagnostics.Metrics;
using RiftCache.Caching;
using RiftCache.Providers.Persistence;
using RiftCache.Tests.TestSupport;

namespace RiftCache.Tests.Caching;

public class CacheMetricsTests
{
[Fact]
public void RecordHit_PublishesToHitsCounter()
{
var metrics = new CacheMetrics(new InMemoryCacheStore(new FakePersistenceProvider(), TimeProvider.System));

// Filters by exact Meter instance, not just CacheMetrics.MeterName -- multiple tests
// create their own CacheMetrics (and therefore their own Meter with the same name), and
// xUnit can run different test classes in parallel, so name-only filtering would leak
// measurements across tests.
using var listener = new MeterListener();
var measurements = new List<long>();
listener.InstrumentPublished = (instrument, l) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.hits")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((_, measurement, _, _) => measurements.Add(measurement));
listener.Start();

metrics.RecordHit();
metrics.RecordHit();

Assert.Equal([1, 1], measurements);
}

[Fact]
public void RecordMiss_PublishesToMissesCounter()
{
var metrics = new CacheMetrics(new InMemoryCacheStore(new FakePersistenceProvider(), TimeProvider.System));

using var listener = new MeterListener();
var measurements = new List<long>();
listener.InstrumentPublished = (instrument, l) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.misses")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((_, measurement, _, _) => measurements.Add(measurement));
listener.Start();

metrics.RecordMiss();

Assert.Equal([1], measurements);
}

[Fact]
public async Task EntriesGauge_ReportsLiveStoreCount()
{
var store = new InMemoryCacheStore(new FakePersistenceProvider(), TimeProvider.System);
await store.SetAsync("a", [1], null, null);
await store.SetAsync("b", [2], null, null);
var metrics = new CacheMetrics(store);

using var listener = new MeterListener();
var measurements = new List<int>();
listener.InstrumentPublished = (instrument, l) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.entries")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<int>((_, measurement, _, _) => measurements.Add(measurement));
listener.Start();
listener.RecordObservableInstruments();

Assert.Equal([2], measurements);
}
}
96 changes: 96 additions & 0 deletions tests/RiftCache.Tests/Caching/MeteredCacheStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Diagnostics.Metrics;
using RiftCache.Caching;
using RiftCache.Providers.Persistence;
using RiftCache.Tests.TestSupport;

namespace RiftCache.Tests.Caching;

public class MeteredCacheStoreTests
{
[Fact]
public async Task GetAsync_ValueFound_ReturnsItAndRecordsHit()
{
var inner = new FakeCacheStore { NextGetResult = [1, 2, 3] };
var metrics = new CacheMetrics(new InMemoryCacheStore(new FakePersistenceProvider(), TimeProvider.System));
var store = new MeteredCacheStore(inner, metrics);

using var listener = new MeterListener();
var hits = new List<long>();
listener.InstrumentPublished = (instrument, l) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.hits")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((_, measurement, _, _) => hits.Add(measurement));
listener.Start();

var result = await store.GetAsync("key");

Assert.Equal(new byte[] { 1, 2, 3 }, result);
Assert.Equal([1], hits);
}

[Fact]
public async Task GetAsync_ValueMissing_ReturnsNullAndRecordsMiss()
{
var inner = new FakeCacheStore { NextGetResult = null };
var metrics = new CacheMetrics(new InMemoryCacheStore(new FakePersistenceProvider(), TimeProvider.System));
var store = new MeteredCacheStore(inner, metrics);

using var listener = new MeterListener();
var misses = new List<long>();
listener.InstrumentPublished = (instrument, l) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.misses")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((_, measurement, _, _) => misses.Add(measurement));
listener.Start();

var result = await store.GetAsync("key");

Assert.Null(result);
Assert.Equal([1], misses);
}

[Fact]
public async Task SetAsync_DelegatesToInner()
{
var store = CreateStore(out var inner);

await store.SetAsync("key", [1], null, null);

Assert.Equal(1, inner.SetCallCount);
}

[Fact]
public async Task RefreshAsync_DelegatesToInnerAndReturnsItsResult()
{
var store = CreateStore(out var inner);
inner.NextRefreshResult = false;

Assert.False(await store.RefreshAsync("key"));
Assert.Equal(1, inner.RefreshCallCount);
}

[Fact]
public async Task RemoveAsync_DelegatesToInnerAndReturnsItsResult()
{
var store = CreateStore(out var inner);
inner.NextRemoveResult = false;

Assert.False(await store.RemoveAsync("key"));
Assert.Equal(1, inner.RemoveCallCount);
}

private static MeteredCacheStore CreateStore(out FakeCacheStore inner)
{
inner = new FakeCacheStore();
var metrics = new CacheMetrics(new InMemoryCacheStore(new FakePersistenceProvider(), TimeProvider.System));
return new MeteredCacheStore(inner, metrics);
}
}
39 changes: 39 additions & 0 deletions tests/RiftCache.Tests/TestSupport/FakeCacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using RiftCache.Caching;

namespace RiftCache.Tests.TestSupport;

internal sealed class FakeCacheStore : ICacheStore
{
public byte[]? NextGetResult { get; set; }

public bool NextRefreshResult { get; set; } = true;

public bool NextRemoveResult { get; set; } = true;

public int SetCallCount { get; private set; }

public int RefreshCallCount { get; private set; }

public int RemoveCallCount { get; private set; }

public Task<byte[]?> GetAsync(string key, CancellationToken token = default) =>
Task.FromResult(NextGetResult);

public Task SetAsync(string key, byte[] value, DateTimeOffset? absoluteExpiration, TimeSpan? slidingExpiration, CancellationToken token = default)
{
SetCallCount++;
return Task.CompletedTask;
}

public Task<bool> RefreshAsync(string key, CancellationToken token = default)
{
RefreshCallCount++;
return Task.FromResult(NextRefreshResult);
}

public Task<bool> RemoveAsync(string key, CancellationToken token = default)
{
RemoveCallCount++;
return Task.FromResult(NextRemoveResult);
}
}
Loading