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:
21
src/admin/package-lock.json
generated
21
src/admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
7
src/admin/src/api/usage.ts
Normal file
7
src/admin/src/api/usage.ts
Normal 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;
|
||||
}
|
||||
119
src/admin/src/components/dashboard/DailyUsageChart.vue
Normal file
119
src/admin/src/components/dashboard/DailyUsageChart.vue
Normal 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} — ${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>
|
||||
49
src/admin/src/components/dashboard/TopFeaturesChart.vue
Normal file
49
src/admin/src/components/dashboard/TopFeaturesChart.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
3
src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs
Normal file
3
src/api/MicCheck.Api/Features/FeatureUsageBucketKey.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);
|
||||
32
src/api/MicCheck.Api/Features/FeatureUsageController.cs
Normal file
32
src/api/MicCheck.Api/Features/FeatureUsageController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/api/MicCheck.Api/Features/FeatureUsageDaily.cs
Normal file
12
src/api/MicCheck.Api/Features/FeatureUsageDaily.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs
Normal file
35
src/api/MicCheck.Api/Features/FeatureUsageMetrics.cs
Normal 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();
|
||||
}
|
||||
42
src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs
Normal file
42
src/api/MicCheck.Api/Features/FeatureUsageQueryService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/api/MicCheck.Api/Features/FeatureUsageResponses.cs
Normal file
9
src/api/MicCheck.Api/Features/FeatureUsageResponses.cs
Normal 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);
|
||||
979
src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs
generated
Normal file
979
src/api/MicCheck.Api/Migrations/20260617194113_AddFeatureUsageDaily.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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