Add feature-usage tracking and dashboard charts
Instruments FeatureEvaluationService with System.Diagnostics.Metrics (Counter + IMeterFactory). Accumulates per-feature/day evaluation counts in-process, flushed every 30s to a new FeatureUsageDaily Postgres table via PostgreSQL ON CONFLICT upsert. Caches dashboard query results for 180s in IMemoryCache. New GET /api/v1/environments/{id}/usage endpoint returns top-10 features last day and 14-day per-day breakdown. Admin dashboard shows two ApexCharts bar charts: top features and evaluations by day with per-flag hover tooltip.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Segments;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
@@ -14,6 +16,7 @@ public class FeatureEvaluationServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private FeatureEvaluationService _service = null!;
|
||||
private FeatureUsageMetrics _usageMetrics = null!;
|
||||
private const int EnvironmentId = 1;
|
||||
private const int ProjectId = 1;
|
||||
|
||||
@@ -25,14 +28,22 @@ public class FeatureEvaluationServiceTests
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
|
||||
var meterFactory = new Mock<IMeterFactory>();
|
||||
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
|
||||
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
|
||||
|
||||
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
|
||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache);
|
||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics);
|
||||
|
||||
SeedBaseData();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
public void TearDown()
|
||||
{
|
||||
_db.Dispose();
|
||||
_usageMetrics.Dispose();
|
||||
}
|
||||
|
||||
private void SeedBaseData()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using MicCheck.Api.Features;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Features;
|
||||
|
||||
[TestFixture]
|
||||
public class FeatureUsageMetricsTests
|
||||
{
|
||||
private FeatureUsageMetrics _metrics = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var meterFactory = new Mock<IMeterFactory>();
|
||||
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
|
||||
_metrics = new FeatureUsageMetrics(meterFactory.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _metrics.Dispose();
|
||||
|
||||
[Test]
|
||||
public void WhenRecordingEvaluations_ThenAccumulatorIncrementsByFeatureAndEnvironment()
|
||||
{
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 20, featureName: "beta");
|
||||
_metrics.RecordEvaluation(environmentId: 2, featureId: 10, featureName: "dark_mode");
|
||||
|
||||
var drained = _metrics.DrainAccumulated();
|
||||
|
||||
Assert.That(drained, Has.Count.EqualTo(3));
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
Assert.That(drained[new FeatureUsageBucketKey(1, 10, "dark_mode", today)], Is.EqualTo(2));
|
||||
Assert.That(drained[new FeatureUsageBucketKey(1, 20, "beta", today)], Is.EqualTo(1));
|
||||
Assert.That(drained[new FeatureUsageBucketKey(2, 10, "dark_mode", today)], Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenDrainCalled_ThenAccumulatorIsResetToEmpty()
|
||||
{
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
|
||||
|
||||
_metrics.DrainAccumulated();
|
||||
var secondDrain = _metrics.DrainAccumulated();
|
||||
|
||||
Assert.That(secondDrain, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenNothingRecorded_ThenDrainReturnsEmptyDictionary()
|
||||
{
|
||||
var drained = _metrics.DrainAccumulated();
|
||||
|
||||
Assert.That(drained, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhenRecordingConcurrently_ThenCountsAreAccurateAndNoDataLost()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int recordsPerThread = 100;
|
||||
|
||||
var tasks = Enumerable.Range(0, threads).Select(_ =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < recordsPerThread; i++)
|
||||
_metrics.RecordEvaluation(environmentId: 1, featureId: 1, featureName: "concurrent_flag");
|
||||
})).ToArray();
|
||||
|
||||
Task.WaitAll(tasks);
|
||||
|
||||
var drained = _metrics.DrainAccumulated();
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
Assert.That(drained[new FeatureUsageBucketKey(1, 1, "concurrent_flag", today)], Is.EqualTo(threads * recordsPerThread));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MicCheck.Api.Tests.Unit.Features;
|
||||
|
||||
[TestFixture]
|
||||
public class FeatureUsageQueryServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private FeatureUsageQueryService _service = null!;
|
||||
private const int EnvironmentId = 1;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
_service = new FeatureUsageQueryService(_db);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
|
||||
{
|
||||
_db.FeatureUsageDaily.Add(new FeatureUsageDaily
|
||||
{
|
||||
EnvironmentId = environmentId,
|
||||
FeatureId = featureId,
|
||||
FeatureName = featureName,
|
||||
UsageDate = date,
|
||||
Count = count,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
_db.SaveChanges();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenNoUsageData_ThenEmptyCollectionsAreReturned()
|
||||
{
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 14);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Is.Empty);
|
||||
Assert.That(result.DailyUsage, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenMultipleFeaturesEvaluatedToday_ThenTopFeaturesLastDayOrdersByCountDescending()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 50);
|
||||
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 200);
|
||||
SeedUsage(EnvironmentId, featureId: 3, "feature_c", today, count: 10);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(3));
|
||||
Assert.That(result.TopFeaturesLastDay[0].FeatureName, Is.EqualTo("feature_b"));
|
||||
Assert.That(result.TopFeaturesLastDay[0].Count, Is.EqualTo(200));
|
||||
Assert.That(result.TopFeaturesLastDay[1].FeatureName, Is.EqualTo("feature_a"));
|
||||
Assert.That(result.TopFeaturesLastDay[2].FeatureName, Is.EqualTo("feature_c"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenMoreThanTenFeatures_ThenTopFeaturesLastDayLimitsToTen()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
for (var i = 1; i <= 15; i++)
|
||||
SeedUsage(EnvironmentId, featureId: i, $"feature_{i}", today, count: i * 10L);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUsageSpansMultipleDays_ThenDailyUsageGroupsByDateAscending()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-2), count: 30);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-1), count: 50);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today, count: 20);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
|
||||
|
||||
Assert.That(result.DailyUsage, Has.Count.EqualTo(3));
|
||||
Assert.That(result.DailyUsage[0].Date, Is.EqualTo(today.AddDays(-2)));
|
||||
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(30));
|
||||
Assert.That(result.DailyUsage[1].TotalCount, Is.EqualTo(50));
|
||||
Assert.That(result.DailyUsage[2].TotalCount, Is.EqualTo(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenHoveringADay_ThenDailyUsageIncludesPerFeatureBreakdownOrderedByCount()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 10);
|
||||
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 40);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
|
||||
|
||||
var day = result.DailyUsage.Single();
|
||||
Assert.That(day.TotalCount, Is.EqualTo(50));
|
||||
Assert.That(day.Features, Has.Count.EqualTo(2));
|
||||
Assert.That(day.Features[0].FeatureName, Is.EqualTo("feature_b"));
|
||||
Assert.That(day.Features[0].Count, Is.EqualTo(40));
|
||||
Assert.That(day.Features[1].FeatureName, Is.EqualTo("feature_a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenUsageExistsOutsideDaysWindow_ThenOldDataIsExcluded()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-30), count: 999);
|
||||
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-3), count: 5);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
|
||||
|
||||
Assert.That(result.DailyUsage, Has.Count.EqualTo(1));
|
||||
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhenOtherEnvironmentHasUsage_ThenItIsNotIncludedInResults()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
SeedUsage(environmentId: 99, featureId: 1, "flag", today, count: 100);
|
||||
|
||||
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
|
||||
|
||||
Assert.That(result.TopFeaturesLastDay, Is.Empty);
|
||||
Assert.That(result.DailyUsage, Is.Empty);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user