diff --git a/src/RiftCache/Caching/CacheMetrics.cs b/src/RiftCache/Caching/CacheMetrics.cs
new file mode 100644
index 0000000..8cef037
--- /dev/null
+++ b/src/RiftCache/Caching/CacheMetrics.cs
@@ -0,0 +1,31 @@
+using System.Diagnostics.Metrics;
+
+namespace RiftCache.Caching;
+
+///
+/// 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(...).
+///
+public sealed class CacheMetrics
+{
+ public const string MeterName = "RiftCache.Caching";
+
+ private readonly Meter _meter;
+ private readonly Counter _hits;
+ private readonly Counter _misses;
+
+ public CacheMetrics(InMemoryCacheStore store)
+ {
+ _meter = new Meter(MeterName);
+ _hits = _meter.CreateCounter("riftcache.cache.hits", description: "Cache reads that found a live entry.");
+ _misses = _meter.CreateCounter("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.");
+ }
+
+ /// Exposed for tests to filter a MeterListener by exact instance rather than just MeterName, since multiple CacheMetrics instances (one per test) share that name.
+ internal Meter Meter => _meter;
+
+ public void RecordHit() => _hits.Add(1);
+
+ public void RecordMiss() => _misses.Add(1);
+}
diff --git a/src/RiftCache/Caching/MeteredCacheStore.cs b/src/RiftCache/Caching/MeteredCacheStore.cs
new file mode 100644
index 0000000..bed07b7
--- /dev/null
+++ b/src/RiftCache/Caching/MeteredCacheStore.cs
@@ -0,0 +1,35 @@
+namespace RiftCache.Caching;
+
+///
+/// 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.
+///
+internal sealed class MeteredCacheStore(ICacheStore inner, CacheMetrics metrics) : ICacheStore
+{
+ public async Task 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 RefreshAsync(string key, CancellationToken token = default) =>
+ inner.RefreshAsync(key, token);
+
+ public Task RemoveAsync(string key, CancellationToken token = default) =>
+ inner.RemoveAsync(key, token);
+}
diff --git a/src/RiftCache/Program.cs b/src/RiftCache/Program.cs
index 565a1cb..6b48781 100644
--- a/src/RiftCache/Program.cs
+++ b/src/RiftCache/Program.cs
@@ -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;
@@ -13,9 +16,39 @@
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
-builder.Services.AddSingleton(sp => sp.GetRequiredService());
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp =>
+ new MeteredCacheStore(sp.GetRequiredService(), sp.GetRequiredService()));
builder.Services.AddHostedService();
+// 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>().Value;
diff --git a/src/RiftCache/RiftCache.csproj b/src/RiftCache/RiftCache.csproj
index 7e27f69..287fcbe 100644
--- a/src/RiftCache/RiftCache.csproj
+++ b/src/RiftCache/RiftCache.csproj
@@ -11,4 +11,10 @@
+
+
+
+
+
+
diff --git a/tests/RiftCache.Tests/Caching/CacheMetricsTests.cs b/tests/RiftCache.Tests/Caching/CacheMetricsTests.cs
new file mode 100644
index 0000000..c88f7c7
--- /dev/null
+++ b/tests/RiftCache.Tests/Caching/CacheMetricsTests.cs
@@ -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();
+ listener.InstrumentPublished = (instrument, l) =>
+ {
+ if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.hits")
+ {
+ l.EnableMeasurementEvents(instrument);
+ }
+ };
+ listener.SetMeasurementEventCallback((_, 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();
+ listener.InstrumentPublished = (instrument, l) =>
+ {
+ if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.misses")
+ {
+ l.EnableMeasurementEvents(instrument);
+ }
+ };
+ listener.SetMeasurementEventCallback((_, 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();
+ listener.InstrumentPublished = (instrument, l) =>
+ {
+ if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.entries")
+ {
+ l.EnableMeasurementEvents(instrument);
+ }
+ };
+ listener.SetMeasurementEventCallback((_, measurement, _, _) => measurements.Add(measurement));
+ listener.Start();
+ listener.RecordObservableInstruments();
+
+ Assert.Equal([2], measurements);
+ }
+}
diff --git a/tests/RiftCache.Tests/Caching/MeteredCacheStoreTests.cs b/tests/RiftCache.Tests/Caching/MeteredCacheStoreTests.cs
new file mode 100644
index 0000000..17f1413
--- /dev/null
+++ b/tests/RiftCache.Tests/Caching/MeteredCacheStoreTests.cs
@@ -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();
+ listener.InstrumentPublished = (instrument, l) =>
+ {
+ if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.hits")
+ {
+ l.EnableMeasurementEvents(instrument);
+ }
+ };
+ listener.SetMeasurementEventCallback((_, 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();
+ listener.InstrumentPublished = (instrument, l) =>
+ {
+ if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "riftcache.cache.misses")
+ {
+ l.EnableMeasurementEvents(instrument);
+ }
+ };
+ listener.SetMeasurementEventCallback((_, 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);
+ }
+}
diff --git a/tests/RiftCache.Tests/TestSupport/FakeCacheStore.cs b/tests/RiftCache.Tests/TestSupport/FakeCacheStore.cs
new file mode 100644
index 0000000..39a4f56
--- /dev/null
+++ b/tests/RiftCache.Tests/TestSupport/FakeCacheStore.cs
@@ -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 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 RefreshAsync(string key, CancellationToken token = default)
+ {
+ RefreshCallCount++;
+ return Task.FromResult(NextRefreshResult);
+ }
+
+ public Task RemoveAsync(string key, CancellationToken token = default)
+ {
+ RemoveCallCount++;
+ return Task.FromResult(NextRemoveResult);
+ }
+}