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-json/ri": "^1.2.0",
|
||||||
"@iconify/vue": "^4.1.0",
|
"@iconify/vue": "^4.1.0",
|
||||||
"@vueuse/core": "^11.0.0",
|
"@vueuse/core": "^11.0.0",
|
||||||
|
"apexcharts": "^5.15.0",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
|
"vue3-apexcharts": "^1.11.1",
|
||||||
"vue3-perfect-scrollbar": "^2.0.0",
|
"vue3-perfect-scrollbar": "^2.0.0",
|
||||||
"vuetify": "^3.7.5"
|
"vuetify": "^3.7.5"
|
||||||
},
|
},
|
||||||
@@ -4281,6 +4283,11 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/argparse": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
@@ -11130,6 +11137,20 @@
|
|||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vue3-perfect-scrollbar": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue3-perfect-scrollbar/-/vue3-perfect-scrollbar-2.0.0.tgz",
|
"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-json/ri": "^1.2.0",
|
||||||
"@iconify/vue": "^4.1.0",
|
"@iconify/vue": "^4.1.0",
|
||||||
"@vueuse/core": "^11.0.0",
|
"@vueuse/core": "^11.0.0",
|
||||||
|
"apexcharts": "^5.15.0",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
|
"vue3-apexcharts": "^1.11.1",
|
||||||
"vue3-perfect-scrollbar": "^2.0.0",
|
"vue3-perfect-scrollbar": "^2.0.0",
|
||||||
"vuetify": "^3.7.5"
|
"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 { createApp } from 'vue';
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
|
import VueApexCharts from 'vue3-apexcharts';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
@@ -15,6 +16,7 @@ const pinia = createPinia();
|
|||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
app.use(VueApexCharts);
|
||||||
installVuetify(app);
|
installVuetify(app);
|
||||||
|
|
||||||
// Restore persisted auth and context state before the first route navigation
|
// Restore persisted auth and context state before the first route navigation
|
||||||
|
|||||||
@@ -476,6 +476,25 @@ export interface FlagResponse {
|
|||||||
featureStateValue: string | null;
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Errors ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ApiValidationError {
|
export interface ApiValidationError {
|
||||||
|
|||||||
@@ -71,6 +71,61 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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 -->
|
<!-- Quick-nav cards -->
|
||||||
<v-row v-if="contextStore.currentProject">
|
<v-row v-if="contextStore.currentProject">
|
||||||
<v-col
|
<v-col
|
||||||
@@ -124,10 +179,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
import { useContextStore } from '@/stores/context';
|
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 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 = [
|
const sections = [
|
||||||
{
|
{
|
||||||
label: 'Features',
|
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<User> Users => Set<User>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
|
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
|
||||||
|
public DbSet<FeatureUsageDaily> FeatureUsageDaily => Set<FeatureUsageDaily>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ namespace MicCheck.Api.Features;
|
|||||||
public class FeatureEvaluationService(
|
public class FeatureEvaluationService(
|
||||||
MicCheckDbContext db,
|
MicCheckDbContext db,
|
||||||
SegmentEvaluator segmentEvaluator,
|
SegmentEvaluator segmentEvaluator,
|
||||||
FlagCache flagCache)
|
FlagCache flagCache,
|
||||||
|
FeatureUsageMetrics usageMetrics)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
||||||
int environmentId, CancellationToken ct = default)
|
int environmentId, CancellationToken ct = default)
|
||||||
@@ -32,6 +33,10 @@ public class FeatureEvaluationService(
|
|||||||
).ToListAsync(ct);
|
).ToListAsync(ct);
|
||||||
|
|
||||||
flagCache.Set(environmentId, results);
|
flagCache.Set(environmentId, results);
|
||||||
|
|
||||||
|
foreach (var r in results)
|
||||||
|
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
|
||||||
|
|
||||||
return results;
|
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;
|
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");
|
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 =>
|
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ try
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddMetrics();
|
||||||
|
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddScoped<AuthService>();
|
builder.Services.AddScoped<AuthService>();
|
||||||
@@ -138,6 +139,9 @@ try
|
|||||||
builder.Services.AddSingleton<WebhookQueue>();
|
builder.Services.AddSingleton<WebhookQueue>();
|
||||||
builder.Services.AddHostedService<WebhookBackgroundService>();
|
builder.Services.AddHostedService<WebhookBackgroundService>();
|
||||||
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
|
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
|
||||||
|
builder.Services.AddSingleton<FeatureUsageMetrics>();
|
||||||
|
builder.Services.AddScoped<FeatureUsageQueryService>();
|
||||||
|
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
|
||||||
builder.Services.AddHttpClient("Webhooks", client =>
|
builder.Services.AddHttpClient("Webhooks", client =>
|
||||||
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
|
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.Diagnostics.Metrics;
|
||||||
using MicCheck.Api.Data;
|
using MicCheck.Api.Data;
|
||||||
using MicCheck.Api.Features;
|
using MicCheck.Api.Features;
|
||||||
using MicCheck.Api.Identities;
|
using MicCheck.Api.Identities;
|
||||||
using MicCheck.Api.Segments;
|
using MicCheck.Api.Segments;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ public class FeatureEvaluationServiceTests
|
|||||||
{
|
{
|
||||||
private MicCheckDbContext _db = null!;
|
private MicCheckDbContext _db = null!;
|
||||||
private FeatureEvaluationService _service = null!;
|
private FeatureEvaluationService _service = null!;
|
||||||
|
private FeatureUsageMetrics _usageMetrics = null!;
|
||||||
private const int EnvironmentId = 1;
|
private const int EnvironmentId = 1;
|
||||||
private const int ProjectId = 1;
|
private const int ProjectId = 1;
|
||||||
|
|
||||||
@@ -25,14 +28,22 @@ public class FeatureEvaluationServiceTests
|
|||||||
.Options;
|
.Options;
|
||||||
_db = new MicCheckDbContext(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()));
|
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
|
||||||
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache);
|
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics);
|
||||||
|
|
||||||
SeedBaseData();
|
SeedBaseData();
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
public void TearDown() => _db.Dispose();
|
public void TearDown()
|
||||||
|
{
|
||||||
|
_db.Dispose();
|
||||||
|
_usageMetrics.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private void SeedBaseData()
|
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