Compare commits

...

2 Commits

Author SHA1 Message Date
James Wampler
6c815a6688 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:45:56 -07:00
James Wampler
b59b56575f Fix identity trait add and persistence bugs
Remove Key from UpsertTraitRequest body (comes from URL route). Emit 'updated' event from IdentityDetail on trait mutations and sync parent. Reload segment data on tab activation and dialog open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:45:45 -07:00
28 changed files with 1850 additions and 4 deletions

View File

@@ -12,10 +12,12 @@
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
"@vueuse/core": "^11.0.0",
"apexcharts": "^5.15.0",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue3-apexcharts": "^1.11.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "^3.7.5"
},
@@ -4281,6 +4283,11 @@
"node": ">= 8"
}
},
"node_modules/apexcharts": {
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.0.tgz",
"integrity": "sha512-ATswoiZi8wynKcwqf0eyE0Is5mBQoDpxZN3PlSVy41OrD+9GMBp2kZQRjyGK+5/j0GFjHkuAd7GKOrt5VMoPrw=="
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -11130,6 +11137,20 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue3-apexcharts": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
"peerDependencies": {
"apexcharts": ">=5.10.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"apexcharts": {
"optional": false
}
}
},
"node_modules/vue3-perfect-scrollbar": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue3-perfect-scrollbar/-/vue3-perfect-scrollbar-2.0.0.tgz",

View File

@@ -14,10 +14,12 @@
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
"@vueuse/core": "^11.0.0",
"apexcharts": "^5.15.0",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue3-apexcharts": "^1.11.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "^3.7.5"
},

View File

@@ -0,0 +1,7 @@
import apiClient from './client';
import type { DashboardUsageResponse } from '@/types/api';
export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> {
const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environments/${environmentId}/usage`, { params: { days } });
return data;
}

View File

@@ -0,0 +1,119 @@
<template>
<apexchart type="bar" :options="chartOptions" :series="series" height="280" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { DailyUsage } from '@/types/api';
const props = defineProps<{
data: DailyUsage[];
}>();
const series = computed(() => [
{
name: 'Evaluations',
data: props.data.map((d) => d.totalCount),
},
]);
function formatDate(iso: string): string {
const parts = iso.split('-');
return parts.length === 3 ? `${parts[1]}/${parts[2]}` : iso;
}
const chartOptions = computed(() => {
const dailyData = props.data;
return {
chart: {
type: 'bar',
toolbar: { show: false },
animations: { enabled: false },
},
plotOptions: {
bar: {
columnWidth: '55%',
borderRadius: 4,
},
},
dataLabels: { enabled: false },
xaxis: {
categories: dailyData.map((d) => d.date),
labels: {
style: { colors: '#8A8D93', fontSize: '12px' },
formatter: (val: string) => formatDate(val),
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: { style: { colors: '#8A8D93', fontSize: '12px' } },
},
colors: ['#8C57FF'],
grid: { borderColor: '#E0E0E0', strokeDashArray: 4 },
tooltip: {
custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
const day = dailyData[dataPointIndex];
if (!day) return '';
const flagRows = day.features
.slice(0, 10)
.map(
(f) =>
`<div class="apex-day-tooltip__row"><span>${f.featureName}</span><strong>${f.count.toLocaleString()}</strong></div>`,
)
.join('');
const moreCount = day.features.length - 10;
const moreRow = moreCount > 0 ? `<div class="apex-day-tooltip__more">+${moreCount} more</div>` : '';
return `
<div class="apex-day-tooltip">
<div class="apex-day-tooltip__header">${day.date} &mdash; ${day.totalCount.toLocaleString()} total</div>
${flagRows}${moreRow}
</div>`;
},
},
};
});
</script>
<style>
.apex-day-tooltip {
padding: 10px 14px;
font-family: inherit;
min-width: 200px;
max-width: 300px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.apex-day-tooltip__header {
font-weight: 600;
margin-bottom: 8px;
font-size: 0.82rem;
color: #2e263d;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 6px;
}
.apex-day-tooltip__row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 0.8rem;
padding: 2px 0;
color: #555;
}
.apex-day-tooltip__row strong {
color: #8c57ff;
font-weight: 600;
}
.apex-day-tooltip__more {
font-size: 0.75rem;
color: #9e9e9e;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<apexchart type="bar" :options="chartOptions" :series="series" height="280" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { TopFeatureUsage } from '@/types/api';
const props = defineProps<{
data: TopFeatureUsage[];
}>();
const series = computed(() => [
{
name: 'Evaluations',
data: props.data.map((f) => f.count),
},
]);
const chartOptions = computed(() => ({
chart: {
type: 'bar',
toolbar: { show: false },
animations: { enabled: false },
},
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
dataLabels: { position: 'top' },
},
},
dataLabels: { enabled: false },
xaxis: {
categories: props.data.map((f) => f.featureName),
labels: { style: { colors: '#8A8D93', fontSize: '12px' } },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: { style: { colors: '#8A8D93', fontSize: '12px' } },
},
colors: ['#8C57FF'],
grid: { borderColor: '#E0E0E0', strokeDashArray: 4, yaxis: { lines: { show: false } } },
tooltip: {
y: { formatter: (val: number) => `${val.toLocaleString()} evaluations` },
},
}));
</script>

View File

@@ -320,6 +320,7 @@ const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
deleted: [identityId: number];
updated: [identity: IdentityResponse];
}>();
const contextStore = useContextStore();
@@ -420,6 +421,10 @@ watch(
{ immediate: true },
);
watch(activeTab, (tab) => {
if (tab === 'segments') loadSegmentsData();
});
async function onToggleOverride(override: FeatureStateResponse, enabled: boolean | null): Promise<void> {
if (enabled === null || !props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
@@ -489,6 +494,12 @@ async function onAddOverride(): Promise<void> {
}
}
function emitTraitsUpdated(): void {
if (props.identity) {
emit('updated', { ...props.identity, traits: [...editedTraits.value] });
}
}
async function onSaveTrait(key: string, value: string): Promise<void> {
if (!props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
@@ -499,6 +510,7 @@ async function onSaveTrait(key: string, value: string): Promise<void> {
await upsertIdentityTrait(envApiKey, props.identity.id, key, { value });
const idx = editedTraits.value.findIndex((t: TraitResponse) => t.key === key);
if (idx >= 0) editedTraits.value[idx] = { key, value };
emitTraitsUpdated();
} catch {
traitsError.value = 'Failed to save trait. Please try again.';
} finally {
@@ -515,6 +527,7 @@ async function onDeleteTrait(key: string): Promise<void> {
try {
await deleteIdentityTrait(envApiKey, props.identity.id, key);
editedTraits.value = editedTraits.value.filter((t: TraitResponse) => t.key !== key);
emitTraitsUpdated();
} catch {
traitsError.value = 'Failed to delete trait. Please try again.';
} finally {
@@ -540,6 +553,7 @@ async function onAddTrait(): Promise<void> {
}
newTraitKey.value = '';
newTraitValue.value = '';
emitTraitsUpdated();
} catch {
traitsError.value = 'Failed to add trait. Please try again.';
} finally {

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import VueApexCharts from 'vue3-apexcharts';
import App from './App.vue';
// Styles
@@ -15,6 +16,7 @@ const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(VueApexCharts);
installVuetify(app);
// Restore persisted auth and context state before the first route navigation

View File

@@ -476,6 +476,25 @@ export interface FlagResponse {
featureStateValue: string | null;
}
// ─── Feature Usage ─────────────────────────────────────────────────────────────
export interface TopFeatureUsage {
featureId: number;
featureName: string;
count: number;
}
export interface DailyUsage {
date: string;
totalCount: number;
features: TopFeatureUsage[];
}
export interface DashboardUsageResponse {
topFeaturesLastDay: TopFeatureUsage[];
dailyUsage: DailyUsage[];
}
// ─── Errors ───────────────────────────────────────────────────────────────────
export interface ApiValidationError {

View File

@@ -71,6 +71,61 @@
</v-col>
</v-row>
<!-- Usage charts (requires environment) -->
<template v-if="contextStore.currentEnvironment">
<v-row class="mb-4">
<!-- Top 10 features last day -->
<v-col cols="12" md="6">
<v-card rounded="lg" data-testid="top-features-chart-card">
<v-card-item class="pb-0">
<v-card-title class="text-body-1 font-weight-medium">Top Features (Last Day)</v-card-title>
<v-card-subtitle class="text-caption">Most evaluated flags in the last 24 hours</v-card-subtitle>
</v-card-item>
<v-card-text class="pt-2">
<div v-if="isLoadingUsage" class="d-flex align-center justify-center" style="height:280px">
<v-progress-circular indeterminate color="primary" />
</div>
<div v-else-if="usageError" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<span class="text-caption">Failed to load usage data</span>
</div>
<div v-else-if="!usageData || usageData.topFeaturesLastDay.length === 0" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<div class="text-center">
<v-icon size="36" color="medium-emphasis" class="mb-2">ri-bar-chart-line</v-icon>
<p class="text-caption">No evaluations recorded yet</p>
</div>
</div>
<TopFeaturesChart v-else :data="usageData.topFeaturesLastDay" />
</v-card-text>
</v-card>
</v-col>
<!-- Usage by day -->
<v-col cols="12" md="6">
<v-card rounded="lg" data-testid="daily-usage-chart-card">
<v-card-item class="pb-0">
<v-card-title class="text-body-1 font-weight-medium">Evaluations by Day</v-card-title>
<v-card-subtitle class="text-caption">Hover a bar to see per-flag counts · Last 14 days</v-card-subtitle>
</v-card-item>
<v-card-text class="pt-2">
<div v-if="isLoadingUsage" class="d-flex align-center justify-center" style="height:280px">
<v-progress-circular indeterminate color="primary" />
</div>
<div v-else-if="usageError" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<span class="text-caption">Failed to load usage data</span>
</div>
<div v-else-if="!usageData || usageData.dailyUsage.length === 0" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<div class="text-center">
<v-icon size="36" color="medium-emphasis" class="mb-2">ri-bar-chart-2-line</v-icon>
<p class="text-caption">No evaluations recorded yet</p>
</div>
</div>
<DailyUsageChart v-else :data="usageData.dailyUsage" />
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<!-- Quick-nav cards -->
<v-row v-if="contextStore.currentProject">
<v-col
@@ -124,10 +179,38 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useContextStore } from '@/stores/context';
import { getUsageDashboard } from '@/api/usage';
import TopFeaturesChart from '@/components/dashboard/TopFeaturesChart.vue';
import DailyUsageChart from '@/components/dashboard/DailyUsageChart.vue';
import type { DashboardUsageResponse } from '@/types/api';
const contextStore = useContextStore();
const usageData = ref<DashboardUsageResponse | null>(null);
const isLoadingUsage = ref(false);
const usageError = ref(false);
async function loadUsage(): Promise<void> {
const envId = contextStore.currentEnvironment?.id;
if (!envId) {
usageData.value = null;
return;
}
isLoadingUsage.value = true;
usageError.value = false;
try {
usageData.value = await getUsageDashboard(envId);
} catch {
usageError.value = true;
} finally {
isLoadingUsage.value = false;
}
}
watch(() => contextStore.currentEnvironment, loadUsage, { immediate: true });
const sections = [
{
label: 'Features',

View File

@@ -91,6 +91,7 @@
v-model="showDetail"
:identity="selectedIdentity"
@deleted="onIdentityDeleted"
@updated="onIdentityUpdated"
/>
<!-- Create identity dialog -->
@@ -246,6 +247,12 @@ function onCreateDialogClosed(): void {
createError.value = null;
}
function onIdentityUpdated(updated: IdentityResponse): void {
const idx = identities.value.findIndex((i) => i.id === updated.id);
if (idx >= 0) identities.value[idx] = updated;
selectedIdentity.value = updated;
}
function onIdentityDeleted(identityId: number): void {
identities.value = identities.value.filter((i) => i.id !== identityId);
showDetail.value = false;

View File

@@ -0,0 +1,19 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureUsageDailyConfiguration : IEntityTypeConfiguration<FeatureUsageDaily>
{
public void Configure(EntityTypeBuilder<FeatureUsageDaily> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.FeatureName).HasMaxLength(150).IsRequired();
builder.Property(u => u.UpdatedAt).IsRequired();
builder.Property(u => u.Count).IsRequired();
builder.HasIndex(u => new { u.EnvironmentId, u.FeatureId, u.UsageDate }).IsUnique();
builder.HasIndex(u => new { u.EnvironmentId, u.UsageDate });
}
}

View File

@@ -37,6 +37,7 @@ public class MicCheckDbContext : DbContext
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
public DbSet<FeatureUsageDaily> FeatureUsageDaily => Set<FeatureUsageDaily>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);

View File

@@ -8,7 +8,8 @@ namespace MicCheck.Api.Features;
public class FeatureEvaluationService(
MicCheckDbContext db,
SegmentEvaluator segmentEvaluator,
FlagCache flagCache)
FlagCache flagCache,
FeatureUsageMetrics usageMetrics)
{
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
int environmentId, CancellationToken ct = default)
@@ -32,6 +33,10 @@ public class FeatureEvaluationService(
).ToListAsync(ct);
flagCache.Set(environmentId, results);
foreach (var r in results)
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
return results;
}
@@ -134,6 +139,9 @@ public class FeatureEvaluationService(
}
}
foreach (var r in results)
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
return results;
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Features;
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);

View File

@@ -0,0 +1,32 @@
using MicCheck.Api.Common.Security.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/environments/{environmentId}/usage")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
int environmentId,
[FromQuery] int days = 14,
CancellationToken ct = default)
{
days = Math.Clamp(days, 1, 90);
var cacheKey = $"usage-dashboard:{environmentId}:{days}";
if (cache.TryGetValue(cacheKey, out DashboardUsageResponse? cached))
return Ok(cached);
var result = await queryService.GetDashboardUsageAsync(environmentId, days, ct);
cache.Set(cacheKey, result, TimeSpan.FromSeconds(180));
return Ok(result);
}
}

View File

@@ -0,0 +1,12 @@
namespace MicCheck.Api.Features;
public class FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; set; }
public DateOnly UsageDate { get; init; }
public long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,60 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory,
ILogger<FeatureUsageFlushBackgroundService> logger) : BackgroundService
{
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(FlushInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await FlushAsync(stoppingToken);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await FlushAsync(CancellationToken.None);
}
private async Task FlushAsync(CancellationToken ct)
{
var deltas = metrics.DrainAccumulated();
if (deltas.Count == 0)
return;
var updatedAt = DateTimeOffset.UtcNow;
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
foreach (var (key, count) in deltas)
{
await db.Database.ExecuteSqlAsync(
$"""
INSERT INTO "FeatureUsageDaily" ("EnvironmentId", "FeatureId", "FeatureName", "UsageDate", "Count", "UpdatedAt")
VALUES ({key.EnvironmentId}, {key.FeatureId}, {key.FeatureName}, {key.UsageDate}, {count}, {updatedAt})
ON CONFLICT ("EnvironmentId", "FeatureId", "UsageDate")
DO UPDATE SET "Count" = "FeatureUsageDaily"."Count" + EXCLUDED."Count",
"FeatureName" = EXCLUDED."FeatureName",
"UpdatedAt" = EXCLUDED."UpdatedAt"
""",
ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to flush feature usage data ({BucketCount} buckets)", deltas.Count);
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
namespace MicCheck.Api.Features;
public class FeatureUsageMetrics : IDisposable
{
private readonly Meter _meter;
private readonly Counter<long> _evaluationCounter;
private ConcurrentDictionary<FeatureUsageBucketKey, long> _accumulator = new();
public FeatureUsageMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create("MicCheck.FeatureUsage");
_evaluationCounter = _meter.CreateCounter<long>("miccheck.feature.evaluations", description: "Number of feature flag evaluations");
}
public void RecordEvaluation(int environmentId, int featureId, string featureName)
{
_evaluationCounter.Add(1,
new KeyValuePair<string, object?>("environment.id", environmentId),
new KeyValuePair<string, object?>("feature.id", featureId));
var key = new FeatureUsageBucketKey(environmentId, featureId, featureName, DateOnly.FromDateTime(DateTime.UtcNow));
_accumulator.AddOrUpdate(key, 1L, (_, existing) => existing + 1);
}
public IReadOnlyDictionary<FeatureUsageBucketKey, long> DrainAccumulated()
{
var drained = Interlocked.Exchange(ref _accumulator, new ConcurrentDictionary<FeatureUsageBucketKey, long>());
return drained;
}
public void Dispose() => _meter.Dispose();
}

View File

@@ -0,0 +1,42 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(MicCheckDbContext db)
{
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var windowStart = today.AddDays(-days);
var yesterday = today.AddDays(-1);
var rows = await db.FeatureUsageDaily
.Where(u => u.EnvironmentId == environmentId && u.UsageDate >= windowStart)
.ToListAsync(ct);
var topFeaturesLastDay = rows
.Where(u => u.UsageDate >= yesterday)
.GroupBy(u => new { u.FeatureId, u.FeatureName })
.Select(g => new TopFeatureUsage(g.Key.FeatureId, g.Key.FeatureName, g.Sum(u => u.Count)))
.OrderByDescending(t => t.Count)
.Take(10)
.ToList();
var dailyUsage = rows
.GroupBy(u => u.UsageDate)
.OrderBy(g => g.Key)
.Select(g =>
{
var features = g
.GroupBy(u => new { u.FeatureId, u.FeatureName })
.Select(fg => new TopFeatureUsage(fg.Key.FeatureId, fg.Key.FeatureName, fg.Sum(u => u.Count)))
.OrderByDescending(f => f.Count)
.ToList();
return new DailyUsage(g.Key, features.Sum(f => f.Count), features);
})
.ToList();
return new DashboardUsageResponse(topFeaturesLastDay, dailyUsage);
}
}

View File

@@ -0,0 +1,9 @@
namespace MicCheck.Api.Features;
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse(
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Identities;
public record UpsertTraitRequest(string Key, string Value);
public record UpsertTraitRequest(string Value);

View File

@@ -0,0 +1,979 @@
// <auto-generated />
using System;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MicCheck.Api.Migrations
{
[DbContext(typeof(MicCheckDbContext))]
[Migration("20260617194113_AddFeatureUsageDaily")]
partial class AddFeatureUsageDaily
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FeatureTags", b =>
{
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<int>("TagsId")
.HasColumnType("integer");
b.HasKey("FeatureId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("FeatureTags");
});
modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("ActorUserId")
.HasColumnType("integer");
b.Property<string>("Changes")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<int?>("ProjectId")
.HasColumnType("integer");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ResourceType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("OrganizationId", "CreatedAt");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MicCheck.Api.Common.Security.ApiKeys.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<string>("Prefix")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.HasIndex("OrganizationId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("MicCheck.Api.Common.Security.Authorization.UserProjectPermission", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean");
b.Property<string>("Permissions")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserId", "ProjectId");
b.ToTable("UserProjectPermissions");
});
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ApiKey")
.IsUnique();
b.HasIndex("ProjectId");
b.ToTable("Environments");
});
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DefaultEnabled")
.HasColumnType("boolean");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("InitialValue")
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Features");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<int>("SegmentId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("FeatureSegments");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<int?>("FeatureSegmentId")
.HasColumnType("integer");
b.Property<int?>("IdentityId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.HasIndex("FeatureSegmentId")
.IsUnique();
b.HasIndex("IdentityId");
b.HasIndex("FeatureId", "EnvironmentId", "IdentityId")
.IsUnique();
b.ToTable("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("Count")
.HasColumnType("bigint");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<string>("FeatureName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("UsageDate")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EnvironmentId", "UsageDate");
b.HasIndex("EnvironmentId", "FeatureId", "UsageDate")
.IsUnique();
b.ToTable("FeatureUsageDaily");
});
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Tags");
});
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("EnvironmentId", "Identifier")
.IsUnique();
b.ToTable("Identities");
});
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("IdentityId")
.HasColumnType("integer");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("ValueType")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("IdentityId", "Key")
.IsUnique();
b.ToTable("IdentityTraits");
});
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteToken")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("InviteToken")
.IsUnique()
.HasFilter("\"InviteToken\" IS NOT NULL");
b.ToTable("Organizations");
});
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
{
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("OrganizationId", "UserId");
b.HasIndex("UserId");
b.ToTable("OrganizationUsers");
});
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HideDisabledFlags")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("Projects");
});
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Segments");
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Operator")
.HasColumnType("integer");
b.Property<string>("Property")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("RuleId")
.HasColumnType("integer");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("RuleId");
b.ToTable("SegmentConditions");
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("ParentRuleId")
.HasColumnType("integer");
b.Property<int>("SegmentId")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ParentRuleId");
b.HasIndex("SegmentId");
b.ToTable("SegmentRules");
});
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsTwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int?>("EnvironmentId")
.HasColumnType("integer");
b.Property<int?>("OrganizationId")
.HasColumnType("integer");
b.Property<int>("Scope")
.HasColumnType("integer");
b.Property<string>("Secret")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.ToTable("Webhooks");
});
modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AttemptNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset>("AttemptedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ResponseBody")
.HasColumnType("text");
b.Property<int?>("ResponseStatusCode")
.HasColumnType("integer");
b.Property<bool>("Success")
.HasColumnType("boolean");
b.Property<int>("WebhookId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WebhookId");
b.ToTable("WebhookDeliveryLogs");
});
modelBuilder.Entity("FeatureTags", b =>
{
b.HasOne("MicCheck.Api.Features.Feature", null)
.WithMany()
.HasForeignKey("FeatureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Features.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Common.Security.ApiKeys.ApiKey", b =>
{
b.HasOne("MicCheck.Api.Organizations.Organization", null)
.WithMany("ApiKeys")
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany("Environments")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany("Features")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
{
b.HasOne("MicCheck.Api.Environments.Environment", null)
.WithMany("FeatureStates")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Features.Feature", null)
.WithMany("FeatureStates")
.HasForeignKey("FeatureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Features.FeatureSegment", null)
.WithOne("FeatureState")
.HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MicCheck.Api.Identities.Identity", null)
.WithMany("FeatureStateOverrides")
.HasForeignKey("IdentityId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
{
b.HasOne("MicCheck.Api.Identities.Identity", null)
.WithMany("Traits")
.HasForeignKey("IdentityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
{
b.HasOne("MicCheck.Api.Organizations.Organization", null)
.WithMany("Members")
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Users.User", "User")
.WithMany("Organizations")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
{
b.HasOne("MicCheck.Api.Organizations.Organization", null)
.WithMany("Projects")
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany("Segments")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
{
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
.WithMany("Conditions")
.HasForeignKey("RuleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
{
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
.WithMany("ChildRules")
.HasForeignKey("ParentRuleId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MicCheck.Api.Segments.Segment", null)
.WithMany("Rules")
.HasForeignKey("SegmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
{
b.HasOne("MicCheck.Api.Users.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
{
b.Navigation("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
{
b.Navigation("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
{
b.Navigation("FeatureState");
});
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
{
b.Navigation("FeatureStateOverrides");
b.Navigation("Traits");
});
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
{
b.Navigation("ApiKeys");
b.Navigation("Members");
b.Navigation("Projects");
});
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
{
b.Navigation("Environments");
b.Navigation("Features");
b.Navigation("Segments");
});
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
{
b.Navigation("Rules");
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
{
b.Navigation("ChildRules");
b.Navigation("Conditions");
});
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
{
b.Navigation("Organizations");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MicCheck.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeatureUsageDaily : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeatureUsageDaily",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EnvironmentId = table.Column<int>(type: "integer", nullable: false),
FeatureId = table.Column<int>(type: "integer", nullable: false),
FeatureName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
UsageDate = table.Column<DateOnly>(type: "date", nullable: false),
Count = table.Column<long>(type: "bigint", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeatureUsageDaily", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_FeatureUsageDaily_EnvironmentId_FeatureId_UsageDate",
table: "FeatureUsageDaily",
columns: new[] { "EnvironmentId", "FeatureId", "UsageDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeatureUsageDaily_EnvironmentId_UsageDate",
table: "FeatureUsageDaily",
columns: new[] { "EnvironmentId", "UsageDate" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeatureUsageDaily");
}
}
}

View File

@@ -302,6 +302,44 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("Count")
.HasColumnType("bigint");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<string>("FeatureName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("UsageDate")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EnvironmentId", "UsageDate");
b.HasIndex("EnvironmentId", "FeatureId", "UsageDate")
.IsUnique();
b.ToTable("FeatureUsageDaily");
});
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
{
b.Property<int>("Id")

View File

@@ -109,6 +109,7 @@ try
});
builder.Services.AddMemoryCache();
builder.Services.AddMetrics();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<AuthService>();
@@ -138,6 +139,9 @@ try
builder.Services.AddSingleton<WebhookQueue>();
builder.Services.AddHostedService<WebhookBackgroundService>();
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
builder.Services.AddSingleton<FeatureUsageMetrics>();
builder.Services.AddScoped<FeatureUsageQueryService>();
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
builder.Services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));

View File

@@ -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()
{

View File

@@ -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));
}
}

View File

@@ -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);
}
}