Groups usage-tracking (metrics, background flush, query service, controller, entity) under its own namespace/folder, updates all consumers and the EF migration snapshot's CLR type strings to match.
149 lines
5.2 KiB
C#
Executable File
149 lines
5.2 KiB
C#
Executable File
using MicCheck.Api.Data;
|
|
using MicCheck.Api.Features.Usage;
|
|
using MicCheck.Api.Identities;
|
|
using MicCheck.Api.Segments;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MicCheck.Api.Features;
|
|
|
|
public class FeatureEvaluationService(
|
|
IMicCheckDbContext db,
|
|
SegmentEvaluator segmentEvaluator,
|
|
FlagCache flagCache,
|
|
FeatureUsageMetrics usageMetrics)
|
|
{
|
|
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
|
int environmentId, CancellationToken ct = default)
|
|
{
|
|
var cached = flagCache.Get(environmentId);
|
|
if (cached is not null)
|
|
return cached;
|
|
|
|
var results = await (
|
|
from fs in db.FeatureStates
|
|
join f in db.Features on fs.FeatureId equals f.Id
|
|
where fs.EnvironmentId == environmentId
|
|
&& fs.IdentityId == null
|
|
&& fs.FeatureSegmentId == null
|
|
select new FeatureStateResult
|
|
{
|
|
Feature = f,
|
|
Enabled = fs.Enabled,
|
|
Value = fs.Value
|
|
}
|
|
).ToListAsync(ct);
|
|
|
|
flagCache.Set(environmentId, results);
|
|
|
|
foreach (var r in results)
|
|
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
|
|
|
|
return results;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
|
|
int environmentId,
|
|
string identifier,
|
|
IReadOnlyList<TraitInput>? traits = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var environment = await db.Environments.FindAsync([environmentId], ct);
|
|
if (environment is null)
|
|
return [];
|
|
|
|
var identity = await new IdentityResolutionService(db)
|
|
.ResolveAsync(environmentId, identifier, traits, ct);
|
|
|
|
var features = await db.Features
|
|
.Where(f => f.ProjectId == environment.ProjectId)
|
|
.ToListAsync(ct);
|
|
|
|
var featureIds = features.Select(f => f.Id).ToList();
|
|
|
|
var allFeatureStates = await db.FeatureStates
|
|
.Where(fs => fs.EnvironmentId == environmentId && featureIds.Contains(fs.FeatureId))
|
|
.ToListAsync(ct);
|
|
|
|
var featureSegments = await db.FeatureSegments
|
|
.Where(fsg => fsg.EnvironmentId == environmentId && featureIds.Contains(fsg.FeatureId))
|
|
.OrderBy(fsg => fsg.Priority)
|
|
.ToListAsync(ct);
|
|
|
|
var segmentIds = featureSegments.Select(fsg => fsg.SegmentId).Distinct().ToList();
|
|
var segments = await db.Segments
|
|
.Include(s => s.Rules)
|
|
.ThenInclude(r => r.Conditions)
|
|
.Include(s => s.Rules)
|
|
.ThenInclude(r => r.ChildRules)
|
|
.ThenInclude(cr => cr.Conditions)
|
|
.Where(s => segmentIds.Contains(s.Id))
|
|
.ToDictionaryAsync(s => s.Id, ct);
|
|
|
|
var identityTraits = identity.Traits.ToList();
|
|
var results = new List<FeatureStateResult>();
|
|
|
|
foreach (var feature in features)
|
|
{
|
|
// 1. Identity-level override
|
|
var identityOverride = allFeatureStates.FirstOrDefault(
|
|
fs => fs.FeatureId == feature.Id && fs.IdentityId == identity.Id);
|
|
if (identityOverride is not null)
|
|
{
|
|
results.Add(new FeatureStateResult
|
|
{
|
|
Feature = feature,
|
|
Enabled = identityOverride.Enabled,
|
|
Value = identityOverride.Value
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 2. Segment overrides (highest priority segment that matches)
|
|
var segmentOverrideFound = false;
|
|
foreach (var fsg in featureSegments.Where(fsg => fsg.FeatureId == feature.Id))
|
|
{
|
|
if (!segments.TryGetValue(fsg.SegmentId, out var segment))
|
|
continue;
|
|
if (!segmentEvaluator.Evaluate(segment, identityTraits, identity.Identifier))
|
|
continue;
|
|
|
|
var segmentState = allFeatureStates.FirstOrDefault(
|
|
fs => fs.FeatureId == feature.Id && fs.FeatureSegmentId == fsg.Id);
|
|
if (segmentState is null)
|
|
continue;
|
|
|
|
results.Add(new FeatureStateResult
|
|
{
|
|
Feature = feature,
|
|
Enabled = segmentState.Enabled,
|
|
Value = segmentState.Value
|
|
});
|
|
segmentOverrideFound = true;
|
|
break;
|
|
}
|
|
if (segmentOverrideFound)
|
|
continue;
|
|
|
|
// 3. Environment default
|
|
var envDefault = allFeatureStates.FirstOrDefault(
|
|
fs => fs.FeatureId == feature.Id
|
|
&& fs.IdentityId == null
|
|
&& fs.FeatureSegmentId == null);
|
|
if (envDefault is not null)
|
|
{
|
|
results.Add(new FeatureStateResult
|
|
{
|
|
Feature = feature,
|
|
Enabled = envDefault.Enabled,
|
|
Value = envDefault.Value
|
|
});
|
|
}
|
|
}
|
|
|
|
foreach (var r in results)
|
|
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
|
|
|
|
return results;
|
|
}
|
|
}
|